diff --git a/packages/server/src/database/system/migrations/20251102082642_create_api_keys_table.js b/packages/server/src/database/system/migrations/20251102082642_create_api_keys_table.js new file mode 100644 index 000000000..c0ce0fd66 --- /dev/null +++ b/packages/server/src/database/system/migrations/20251102082642_create_api_keys_table.js @@ -0,0 +1,36 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('api_keys', (table) => { + table.increments(); + table.string('key').notNullable().unique().index(); + table.string('name'); + table + .integer('user_id') + .unsigned() + .notNullable() + .index() + .references('id') + .inTable('users'); + table + .bigInteger('tenant_id') + .unsigned() + .notNullable() + .index() + .references('id') + .inTable('tenants'); + table.dateTime('expires_at').nullable().index(); + table.dateTime('revoked_at').nullable().index(); + table.timestamps(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('api_keys'); +}; diff --git a/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts b/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts index 090b9e1bd..f5d1d971d 100644 --- a/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts +++ b/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Param, Get, Put } from '@nestjs/common'; +import { Controller, Post, Param, Get, Put, Body } from '@nestjs/common'; import { GenerateApiKey } from './commands/GenerateApiKey.service'; import { GetApiKeysService } from './queries/GetApiKeys.service'; import { @@ -8,6 +8,8 @@ import { ApiParam, ApiExtraModels, getSchemaPath, + ApiBody, + ApiProperty, } from '@nestjs/swagger'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { @@ -16,6 +18,20 @@ import { ApiKeyListResponseDto, ApiKeyListItemDto, } from './dtos/ApiKey.dto'; +import { IsString, MaxLength } from 'class-validator'; +import { IsOptional } from '@/common/decorators/Validators'; + +class GenerateApiKeyDto { + @IsOptional() + @IsString() + @MaxLength(255) + @ApiProperty({ + description: 'Optional name for the API key', + required: false, + example: 'My API Key', + }) + name?: string; +} @Controller('api-keys') @ApiTags('Api keys') @@ -29,17 +45,18 @@ export class AuthApiKeysController { constructor( private readonly getApiKeysService: GetApiKeysService, private readonly generateApiKeyService: GenerateApiKey, - ) {} + ) { } @Post('generate') @ApiOperation({ summary: 'Generate a new API key' }) + @ApiBody({ type: GenerateApiKeyDto }) @ApiResponse({ status: 201, description: 'The generated API key', type: ApiKeyResponseDto, }) - async generate() { - return this.generateApiKeyService.generate(); + async generate(@Body() body: GenerateApiKeyDto) { + return this.generateApiKeyService.generate(body.name); } @Put(':id/revoke') diff --git a/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts b/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts index 5f57201b7..2b91c60e0 100644 --- a/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts +++ b/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts @@ -10,14 +10,15 @@ export class GenerateApiKey { private readonly tenancyContext: TenancyContext, @Inject(ApiKeyModel.name) private readonly apiKeyModel: typeof ApiKeyModel, - ) {} + ) { } /** * Generates a new secure API key for the current tenant and system user. * The key is saved in the database and returned (only the key and id for security). + * @param {string} name - Optional name for the API key. * @returns {Promise<{ key: string; id: number }>} The generated API key and its database id. */ - async generate() { + async generate(name?: string) { const tenant = await this.tenancyContext.getTenant(); const user = await this.tenancyContext.getSystemUser(); @@ -26,6 +27,7 @@ export class GenerateApiKey { // Save the API key to the database const apiKeyRecord = await this.apiKeyModel.query().insert({ key, + name, tenantId: tenant.id, userId: user.id, createdAt: new Date(), diff --git a/packages/server/src/modules/Auth/dtos/ApiKey.dto.ts b/packages/server/src/modules/Auth/dtos/ApiKey.dto.ts index 1929c163d..edbc070c3 100644 --- a/packages/server/src/modules/Auth/dtos/ApiKey.dto.ts +++ b/packages/server/src/modules/Auth/dtos/ApiKey.dto.ts @@ -29,6 +29,12 @@ export class ApiKeyListItemDto { @ApiProperty({ example: 'My API Key', description: 'API key name' }) name?: string; + @ApiProperty({ + example: 'bc_1234...', + description: 'First 8 characters of the API key token', + }) + token: string; + @ApiProperty({ example: '2024-01-01T00:00:00.000Z', description: 'Creation date', diff --git a/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts b/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts index 8d4d5d128..a7bfd938f 100644 --- a/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts +++ b/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts @@ -1,7 +1,16 @@ import { Transformer } from '@/modules/Transformer/Transformer'; export class GetApiKeysTransformer extends Transformer { + + public includeAttributes = (): string[] => { + return ['token']; + }; + public excludeAttributes = (): string[] => { return ['tenantId']; }; + + public token(apiKey) { + return apiKey.key ? `${apiKey.key.substring(0, 8)}...` : ''; + } } diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index a97200483..7f7d2729a 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -49,6 +49,7 @@ import { RuleFormDialog } from '@/containers/Banking/Rules/RuleFormDialog/RuleFo import { DisconnectBankAccountDialog } from '@/containers/CashFlow/AccountTransactions/dialogs/DisconnectBankAccountDialog/DisconnectBankAccountDialog'; import { SharePaymentLinkDialog } from '@/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog'; import { SelectPaymentMethodsDialog } from '@/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog'; +import ApiKeysGenerateDialog from '@/containers/Dialogs/ApiKeysGenerateDialog'; /** * Dialogs container. @@ -147,6 +148,9 @@ export default function DialogsContainer() { + ); } diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts index f9e51e735..92a1ce5ae 100644 --- a/packages/webapp/src/constants/dialogs.ts +++ b/packages/webapp/src/constants/dialogs.ts @@ -80,5 +80,6 @@ export enum DialogsName { SharePaymentLink = 'SharePaymentLink', SelectPaymentMethod = 'SelectPaymentMethodsDialog', - StripeSetup = 'StripeSetup' + StripeSetup = 'StripeSetup', + ApiKeysGenerate = 'api-keys-generate' } diff --git a/packages/webapp/src/constants/preferencesMenu.tsx b/packages/webapp/src/constants/preferencesMenu.tsx index 468cfd89d..72f01c4ad 100644 --- a/packages/webapp/src/constants/preferencesMenu.tsx +++ b/packages/webapp/src/constants/preferencesMenu.tsx @@ -68,6 +68,11 @@ export default [ disabled: false, href: '/preferences/integrations' }, + { + text: 'API Keys', + disabled: false, + href: '/preferences/api-keys', + }, // { // text: , // disabled: false, diff --git a/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeyDisplayView.tsx b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeyDisplayView.tsx new file mode 100644 index 000000000..c675bce58 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeyDisplayView.tsx @@ -0,0 +1,72 @@ +// @ts-nocheck +import React from 'react'; +import { + Classes, + Button, + InputGroup, + FormGroup, + Intent, + Position, + Tooltip, +} from '@blueprintjs/core'; +import intl from 'react-intl-universal'; +import { FormattedMessage as T, Icon, Alert } from '@/components'; +import { useClipboard } from '@/hooks/utils/useClipboard'; +import { AppToaster } from '@/components'; + +/** + * API Key Display view component (used within the generate dialog). + */ +function ApiKeyDisplayView({ + dialogName, + apiKey, + onClose, +}) { + const clipboard = useClipboard(); + + const handleCopy = () => { + if (apiKey) { + clipboard.copy(apiKey); + AppToaster.show({ + message: intl.get('api_key.copied_to_clipboard'), + intent: Intent.SUCCESS, + }); + } + }; + + return ( + <> +
+ + {intl.get('api_key.important')}: {intl.get('api_key.display_warning')} + + + +
+ +
+
+ +
+
+ + ); +} + +export default ApiKeyDisplayView; + diff --git a/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateDialog.tsx b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateDialog.tsx new file mode 100644 index 000000000..d9bfabf48 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateDialog.tsx @@ -0,0 +1,35 @@ +// @ts-nocheck +import React, { useState } from 'react'; +import { Dialog, DialogSuspense, FormattedMessage as T } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const ApiKeysGenerateDialogContent = React.lazy( + () => import('./ApiKeysGenerateDialogContent'), +); + +/** + * API Keys Generate dialog. + */ +function ApiKeysGenerateDialog({ dialogName, payload, isOpen }) { + return ( + + } + isOpen={isOpen} + canEscapeJeyClose={true} + autoFocus={true} + className={'dialog--api-keys-generate'} + style={{ width: '500px' }} + > + + + + + ); +} +export default compose(withDialogRedux())(ApiKeysGenerateDialog); diff --git a/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateDialogContent.tsx b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateDialogContent.tsx new file mode 100644 index 000000000..c904f8264 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateDialogContent.tsx @@ -0,0 +1,86 @@ +// @ts-nocheck +import React, { useState, useEffect } from 'react'; +import { Formik } from 'formik'; +import intl from 'react-intl-universal'; +import { Intent } from '@blueprintjs/core'; +import { AppToaster } from '@/components'; +import { useGenerateApiKey } from '@/hooks/query'; +import ApiKeysGenerateFormContent from './ApiKeysGenerateFormContent'; +import ApiKeysGenerateFormSchema from './ApiKeysGenerateForm.schema'; +import ApiKeyDisplayView from './ApiKeyDisplayView'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { compose } from '@/utils'; + +const defaultInitialValues = { + name: '', +}; + +/** + * API Keys Generate form dialog content. + */ +function ApiKeysGenerateDialogContent({ + // #withDialogActions + closeDialog, + dialogName, +}) { + const [generatedApiKey, setGeneratedApiKey] = useState(null); + const generateApiKeyMutate = useGenerateApiKey(); + + // Handles the form submit. + const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + const form = { name: values.name || undefined }; + + // Handle request response errors. + const handleError = (error) => { + const errors = error?.response?.data?.errors; + if (errors) { + const errorsTransformed = Object.keys(errors).reduce((acc, key) => { + acc[key] = errors[key][0]; + return acc; + }, {}); + setErrors(errorsTransformed); + } + setSubmitting(false); + }; + + generateApiKeyMutate.mutate(form, { + onSuccess: (response) => { + // The API returns { key, id }, which might be wrapped in response.data + const apiKey = response?.data?.key || response?.key; + if (apiKey) { + setGeneratedApiKey(apiKey); + } else { + setSubmitting(false); + } + }, + onError: handleError, + }); + }; + + // If API key has been generated, show the display view + if (generatedApiKey) { + return ( + { + setGeneratedApiKey(null); + closeDialog(dialogName); + }} + /> + ); + } + + // Otherwise, show the generate form + return ( + + + + ); +} + +export default compose(withDialogActions)(ApiKeysGenerateDialogContent); diff --git a/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateForm.schema.tsx b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateForm.schema.tsx new file mode 100644 index 000000000..eabc5a65d --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateForm.schema.tsx @@ -0,0 +1,10 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +export const CreateApiKeyFormSchema = Yup.object().shape({ + name: Yup.string() + .required('Name is required') + .max(255, 'Name must be at most 255 characters'), +}); + +export default CreateApiKeyFormSchema; diff --git a/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateFormContent.tsx b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateFormContent.tsx new file mode 100644 index 000000000..9783c47db --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/ApiKeysGenerateFormContent.tsx @@ -0,0 +1,67 @@ +// @ts-nocheck +import React from 'react'; +import { Form, useFormikContext } from 'formik'; +import { + Classes, + Button, + Intent, +} from '@blueprintjs/core'; +import { FastField, ErrorMessage } from 'formik'; +import { + FormGroup, + InputGroup, +} from '@blueprintjs/core'; +import intl from 'react-intl-universal'; +import { inputIntent } from '@/utils'; +import { FFormGroup, FInputGroup, FormattedMessage as T } from '@/components'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { compose } from '@/utils'; + +/** + * API Keys Generate form content. + */ +function ApiKeysGenerateFormContent({ + dialogName, + // #withDialogActions + closeDialog, +}) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + closeDialog(dialogName); + }; + + return ( +
+
+ {/* ----------- Name ----------- */} + } + > + + +
+ +
+
+ + + +
+
+
+ ); +} + +export default compose(withDialogActions)(ApiKeysGenerateFormContent); diff --git a/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/index.tsx b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/index.tsx new file mode 100644 index 000000000..25ad63c08 --- /dev/null +++ b/packages/webapp/src/containers/Dialogs/ApiKeysGenerateDialog/index.tsx @@ -0,0 +1 @@ +export { default } from './ApiKeysGenerateDialog'; diff --git a/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeys.tsx b/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeys.tsx new file mode 100644 index 000000000..cf408e0c9 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeys.tsx @@ -0,0 +1,42 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import classNames from 'classnames'; +import styled from 'styled-components'; + +import { Card } from '@/components'; +import { CLASSES } from '@/constants/classes'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; +import ApiKeysDataTable from './ApiKeysDataTable'; +import { compose } from '@/utils'; + +/** + * API Keys preferences page. + */ +function ApiKeysPreferences({ + // #withDashboardActions + changePreferencesPageTitle, +}) { + useEffect(() => { + changePreferencesPageTitle(intl.get('api_key.title')); + }, [changePreferencesPageTitle]); + + return ( +
+ + + +
+ ); +} + +const ApiKeysPreferencesCard = styled(Card)` + padding: 0; +`; + +export default compose(withDashboardActions)(ApiKeysPreferences); diff --git a/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeysActions.tsx b/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeysActions.tsx new file mode 100644 index 000000000..5916cdd2b --- /dev/null +++ b/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeysActions.tsx @@ -0,0 +1,29 @@ +// @ts-nocheck +import React from 'react'; + +import { Button, Intent } from '@blueprintjs/core'; +import { Icon, FormattedMessage as T } from '@/components'; + +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { compose } from '@/utils'; + +function ApiKeysActions({ openDialog, closeDialog }) { + const onClickGenerateApiKey = () => { + openDialog('api-keys-generate'); + }; + + return ( +
+ +
+ ); +} + +export default compose(withDialogActions)(ApiKeysActions); + diff --git a/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeysDataTable.tsx b/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeysDataTable.tsx new file mode 100644 index 000000000..0c3ff5958 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/ApiKeys/ApiKeysDataTable.tsx @@ -0,0 +1,65 @@ +// @ts-nocheck +import React, { useCallback } from 'react'; +import { compose } from '@/utils'; +import { DataTable, TableSkeletonRows, AppToaster } from '@/components'; +import { useApiKeys, useRevokeApiKey } from '@/hooks/query'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withAlertActions from '@/containers/Alert/withAlertActions'; +import { ActionsMenu, useApiKeysTableColumns } from './components'; +import { Intent } from '@blueprintjs/core'; +import intl from 'react-intl-universal'; + +/** + * API Keys datatable. + */ +function ApiKeysDataTable({ + // #withDialogActions + openDialog, + + // #withAlertActions + openAlert, +}) { + const { data: apiKeys, isLoading, isFetching } = useApiKeys(); + const { mutateAsync: revokeApiKey } = useRevokeApiKey(); + + // API Keys list columns. + const columns = useApiKeysTableColumns(); + + // Handle revoke API key action. + const handleRevokeApiKey = useCallback( + (apiKey) => { + revokeApiKey(apiKey.id) + .then(() => { + AppToaster.show({ + message: intl.get('api_key.revoke_success'), + intent: Intent.SUCCESS, + }); + }) + .catch((error) => { + AppToaster.show({ + message: error?.response?.data?.message || intl.get('something_went_wrong'), + intent: Intent.DANGER, + }); + }); + }, + [revokeApiKey], + ); + + return ( + + ); +} + +export default compose(withDialogActions, withAlertActions)(ApiKeysDataTable); diff --git a/packages/webapp/src/containers/Preferences/ApiKeys/components.tsx b/packages/webapp/src/containers/Preferences/ApiKeys/components.tsx new file mode 100644 index 000000000..d03352ffc --- /dev/null +++ b/packages/webapp/src/containers/Preferences/ApiKeys/components.tsx @@ -0,0 +1,135 @@ +// @ts-nocheck +import React from 'react'; +import intl from 'react-intl-universal'; +import { FormattedMessage as T, Icon } from '@/components'; +import { + Intent, + Button, + Popover, + Menu, + MenuItem, + Position, + Tag, +} from '@blueprintjs/core'; +import { safeCallback } from '@/utils'; +import { FormatDateCell } from '@/components/Utils/FormatDate'; + +/** + * API Keys table actions menu. + */ +export function ActionsMenu({ + row: { original }, + payload: { onRevoke }, +}) { + return ( + + } + text={intl.get('api_key.revoke')} + onClick={safeCallback(onRevoke, original)} + intent={Intent.DANGER} + /> + + ); +} + +/** + * Token accessor. + * Displays the token value in a Tag component. + */ +function TokenAccessor(apiKey) { + return ( + + {apiKey.token || ''} + + ); +} + +/** + * Permissions accessor. + * Since permissions aren't currently stored, we show a default message. + */ +function PermissionsAccessor(apiKey) { + return ( + + + + ); +} + +/** + * Last Used accessor. + * Since lastUsed isn't currently tracked, we show "Never". + */ +function LastUsedAccessor(apiKey) { + return {intl.get('api_key.never')}; +} + +/** + * Actions cell. + */ +function ActionsCell(props) { + return ( + } + position={Position.RIGHT_BOTTOM} + > +