From 9c2344d432edf6f5c958b99d19f6cce9274cce4a Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Tue, 17 Mar 2026 10:39:47 -0400 Subject: [PATCH 1/9] feat: API token management in workspace settings Add UI and backend support for creating, listing, and revoking API tokens scoped to workspaces. Includes owner-level workspace token visibility, OpenAPI documentation, Mongo/Postgres persistence, and i18n translations. Signed-off-by: Don Kendall --- docs/openapi.yaml | 456 ++++++++++++++++++ .../packages/account-client/src/client.ts | 52 ++ .../core/packages/account-client/src/types.ts | 16 + models/setting/src/index.ts | 13 + plugins/setting-assets/assets/icons.svg | 3 + plugins/setting-assets/lang/cs.json | 38 +- plugins/setting-assets/lang/de.json | 37 +- plugins/setting-assets/lang/en.json | 36 +- plugins/setting-assets/lang/es.json | 45 +- plugins/setting-assets/lang/fr.json | 36 +- plugins/setting-assets/lang/it.json | 36 +- plugins/setting-assets/lang/ja.json | 36 +- plugins/setting-assets/lang/pt-br.json | 45 +- plugins/setting-assets/lang/pt.json | 45 +- plugins/setting-assets/lang/ru.json | 36 +- plugins/setting-assets/lang/tr.json | 36 +- plugins/setting-assets/lang/zh.json | 36 +- plugins/setting-assets/src/index.ts | 3 +- .../src/components/ApiDocsSection.svelte | 240 +++++++++ .../src/components/ApiTokenCreatePopup.svelte | 179 +++++++ .../src/components/ApiTokens.svelte | 204 ++++++++ plugins/setting-resources/src/index.ts | 4 +- plugins/setting/src/index.ts | 45 +- server/account/src/collections/mongo.ts | 6 +- .../src/collections/postgres/migrations.ts | 34 +- .../src/collections/postgres/postgres.ts | 9 + server/account/src/operations.ts | 222 +++++++++ server/account/src/types.ts | 19 + 28 files changed, 1947 insertions(+), 20 deletions(-) create mode 100644 docs/openapi.yaml create mode 100644 plugins/setting-resources/src/components/ApiDocsSection.svelte create mode 100644 plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte create mode 100644 plugins/setting-resources/src/components/ApiTokens.svelte diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 00000000000..fe22bbfba9f --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,456 @@ +openapi: 3.1.0 +info: + title: Huly Self-Hosted API + version: 0.1.0 + description: | + REST API for Huly self-hosted instances. + + ## Authentication + + All data endpoints require a workspace-scoped JWT in the `Authorization: Bearer ` header. + Tokens can be minted using the CLI tool (`tools/mint-token/`) or the optional token service (`/_tokens`). + + ## Getting Started + + 1. Mint a token using the CLI tool or token service + 2. Use the token to query the transactor REST API + 3. The `find-all` endpoint queries documents by class + 4. The `tx` endpoint submits transactions (create, update, delete) + + license: + name: EPL-2.0 + url: https://www.eclipse.org/legal/epl-2.0/ + +servers: + - url: https://{host} + description: Your Huly self-hosted instance + variables: + host: + default: localhost:8083 + +security: + - bearerAuth: [] + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + Workspace-scoped JWT. Payload: `{ account: AccountUuid, workspace: WorkspaceUuid, exp: number }`. + Signed with `SERVER_SECRET` using HS256. + + serverSecret: + type: apiKey + in: header + name: X-Server-Secret + description: SERVER_SECRET value — used for admin-only token service endpoints. + + schemas: + TxOperation: + type: object + description: A Huly transaction object + properties: + _class: + type: string + description: Transaction class (e.g. `core:class:TxCreateDoc`) + example: 'core:class:TxCreateDoc' + objectClass: + type: string + description: Target document class + example: 'tracker:class:Issue' + objectSpace: + type: string + description: Target space + example: 'tracker:project:DefaultProject' + objectId: + type: string + description: Document ID (generated or existing) + attributes: + type: object + description: Document attributes to set + required: + - _class + + FindAllResponse: + type: object + properties: + result: + type: array + items: + type: object + description: Array of matching documents + total: + type: integer + description: Total count of matching documents + + TokenRequest: + type: object + properties: + email: + type: string + format: email + description: User email — resolved to account UUID via CockroachDB + example: user@example.com + workspace: + type: string + description: Workspace URL slug — resolved to workspace UUID via CockroachDB + example: my-workspace + expiryDays: + type: integer + minimum: 1 + maximum: 365 + default: 30 + description: Token lifetime in days + required: + - email + - workspace + + TokenResponse: + type: object + properties: + id: + type: string + description: Token metadata ID (for listing/revoking) + token: + type: string + description: Signed JWT + expiresAt: + type: string + format: date-time + description: Token expiration timestamp + + TokenMetadata: + type: object + properties: + id: + type: string + email: + type: string + workspace: + type: string + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + revoked: + type: boolean + + JsonRpcRequest: + type: object + properties: + method: + type: string + description: RPC method name + params: + type: array + items: {} + description: Method parameters + required: + - method + - params + + JsonRpcResponse: + type: object + properties: + id: + type: string + result: + description: Method-specific result + + Error: + type: object + properties: + error: + type: string + +paths: + # ── Account Service (JSON-RPC) ────────────────────────────────────── + + /_accounts: + post: + operationId: accountRpc + tags: [Auth] + summary: Account service JSON-RPC + description: | + JSON-RPC endpoint for the account service. Key methods: + + - `login(params: [email, password])` → returns account JWT + - `selectWorkspace(params: [workspaceUrl, kind, allowAdmin])` → returns workspace JWT + endpoint + - `getUserWorkspaces()` → list workspaces for authenticated user + + Note: For API token-based access, you don't need these — mint a token directly. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JsonRpcRequest' + examples: + login: + summary: Login + value: + method: login + params: ['user@example.com', 'password'] + selectWorkspace: + summary: Select workspace + value: + method: selectWorkspace + params: ['my-workspace', 'external', false] + responses: + '200': + description: JSON-RPC response + content: + application/json: + schema: + $ref: '#/components/schemas/JsonRpcResponse' + '400': + description: Malformed request + + # ── Transactor REST API ───────────────────────────────────────────── + + /_transactor/api/v1/find-all/{workspace}: + get: + operationId: findAll + tags: [Data] + summary: Query documents by class + description: | + Find all documents matching a class and optional query/options filters. + Returns an array of matching documents. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + example: 'ws-abc-123' + - name: class + in: query + required: true + schema: + type: string + description: Huly class reference + example: 'contact:class:Person' + - name: query + in: query + schema: + type: string + description: JSON-encoded query filter + example: '{"name": "John"}' + - name: options + in: query + schema: + type: string + description: JSON-encoded options (limit, sort, etc.) + example: '{"limit": 10}' + responses: + '200': + description: Matching documents + content: + application/json: + schema: + $ref: '#/components/schemas/FindAllResponse' + '401': + description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /_transactor/api/v1/tx/{workspace}: + post: + operationId: submitTx + tags: [Data] + summary: Submit a transaction + description: | + Submit a transaction to create, update, or delete documents. + The transaction format follows the Huly platform transaction model. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TxOperation' + examples: + createIssue: + summary: Create a tracker issue + value: + _class: 'core:class:TxCreateDoc' + objectClass: 'tracker:class:Issue' + objectSpace: 'tracker:project:DefaultProject' + attributes: + title: 'Fix login bug' + description: 'Users cannot log in with SSO' + priority: 1 + responses: + '200': + description: Transaction result + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + /_transactor/api/v1/load-model/{workspace}: + get: + operationId: loadModel + tags: [Data] + summary: Load data model / schema + description: | + Returns the full data model (class hierarchy, mixins, attributes) for the workspace. + Useful for discovering available classes and their fields. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Data model + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + /_transactor/api/v1/ping/{workspace}: + get: + operationId: ping + tags: [Data] + summary: Health check + description: | + Verifies the token is valid and the workspace is reachable. + Returns a simple status response. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Workspace is reachable + content: + application/json: + schema: + type: object + properties: + pong: + type: boolean + '401': + description: Invalid or expired token + + /_transactor/api/v1/account/{workspace}: + get: + operationId: getAccountInfo + tags: [Data] + summary: Get account info + description: | + Returns information about the authenticated account in the context of the given workspace, + including the account UUID and workspace role. + parameters: + - name: workspace + in: path + required: true + schema: + type: string + description: Workspace UUID + responses: + '200': + description: Account information + content: + application/json: + schema: + type: object + '401': + description: Invalid or expired token + + # ── Token Service (optional) ──────────────────────────────────────── + + /_tokens: + post: + operationId: mintToken + tags: [Tokens] + summary: Mint an API token + description: | + Mint a workspace-scoped JWT. Requires admin auth via `X-Server-Secret` header. + Resolves email → account UUID and workspace slug → workspace UUID automatically. + + Only available when `tokenService.enabled: true` in Helm values. + security: + - serverSecret: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: Minted token + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + '401': + description: Invalid server secret + '404': + description: Email or workspace not found + + get: + operationId: listTokens + tags: [Tokens] + summary: List minted tokens + description: Returns metadata for all minted tokens (token values are not stored). + security: + - serverSecret: [] + responses: + '200': + description: Token list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TokenMetadata' + '401': + description: Invalid server secret + + /_tokens/{id}: + delete: + operationId: revokeToken + tags: [Tokens] + summary: Revoke a token + description: | + Soft-revoke a token by marking it as revoked in the database. + Note: The token will still be valid until it expires, as full revocation + requires a denylist check at the transactor level (future enhancement). + security: + - serverSecret: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Token revoked + '404': + description: Token not found diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 1a8e6ea4308..5d666f1c43a 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -35,6 +35,8 @@ import { import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import type { AccountAggregatedInfo, + ApiTokenInfo, + ApiTokenResult, Integration, IntegrationKey, IntegrationSecret, @@ -255,6 +257,11 @@ export interface AccountClient { getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise verify2fa: (code: string) => Promise + createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number) => Promise + listApiTokens: () => Promise + revokeApiToken: (tokenId: string) => Promise + listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise + revokeWorkspaceApiToken: (tokenId: string, workspaceUuid: WorkspaceUuid) => Promise setCookie: () => Promise deleteCookie: () => Promise @@ -1224,6 +1231,51 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } + async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number): Promise { + const request = { + method: 'createApiToken' as const, + params: { name, workspaceUuid, expiryDays } + } + + return await this.rpc(request) + } + + async listApiTokens (): Promise { + const request = { + method: 'listApiTokens' as const, + params: {} + } + + return await this.rpc(request) + } + + async revokeApiToken (tokenId: string): Promise { + const request = { + method: 'revokeApiToken' as const, + params: { tokenId } + } + + await this.rpc(request) + } + + async listWorkspaceApiTokens (workspaceUuid: WorkspaceUuid): Promise { + const request = { + method: 'listWorkspaceApiTokens' as const, + params: { workspaceUuid } + } + + return await this.rpc(request) + } + + async revokeWorkspaceApiToken (tokenId: string, workspaceUuid: WorkspaceUuid): Promise { + const request = { + method: 'revokeWorkspaceApiToken' as const, + params: { tokenId, workspaceUuid } + } + + await this.rpc(request) + } + async setCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'PUT' }) diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index c9f791f3c21..ac7c757d5e8 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -112,6 +112,22 @@ export interface MailboxInfo { appPasswords: string[] } +export interface ApiTokenInfo { + id: string + name: string + workspaceUuid: WorkspaceUuid + workspaceName: string + createdOn: number + expiresOn: number + revoked: boolean +} + +export interface ApiTokenResult { + id: string + token: string + expiresOn: number +} + export interface MailboxSecret { mailbox: string app?: string diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index c063d87aa4b..24237d3afc7 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -440,6 +440,19 @@ export function createModel (builder: Builder): void { setting.ids.OfficeSettings ) + builder.createDoc( + setting.class.WorkspaceSettingCategory, + core.space.Model, + { + name: 'apiTokens', + label: setting.string.ApiTokens, + icon: setting.icon.ApiToken, + component: setting.component.ApiTokens, + order: 1050, + role: AccountRole.Owner + }, + setting.ids.ApiTokens + ) // Currently remove Support item from settings // builder.createDoc( // setting.class.SettingsCategory, diff --git a/plugins/setting-assets/assets/icons.svg b/plugins/setting-assets/assets/icons.svg index 22429b72110..f96d4a989c4 100644 --- a/plugins/setting-assets/assets/icons.svg +++ b/plugins/setting-assets/assets/icons.svg @@ -98,4 +98,7 @@ + + + diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index 4116dec9721..df6677bba2e 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -237,6 +237,42 @@ "TwoFactorAuthEnabled": "Dvoufaktorové ověřování je povoleno", "TwoFactorAuthDisabled": "Dvoufaktorové ověřování je zakázáno", "ShowQRCode": "Zobrazit QR kód", - "EnterVerificationCode": "Zadejte ověřovací kód" + "EnterVerificationCode": "Zadejte ověřovací kód", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "Login": "Login", + "Primary": "Primary", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index f854dd57b7d..6fbf8f546a0 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -239,6 +239,41 @@ "TwoFactorAuthEnabled": "Zweistufige Authentifizierung ist aktiviert", "TwoFactorAuthDisabled": "Zweistufige Authentifizierung ist deaktiviert", "ShowQRCode": "QR-Code anzeigen", - "EnterVerificationCode": "Verifizierungscode eingeben" + "EnterVerificationCode": "Verifizierungscode eingeben", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "BetaWarning": "Modules labeled as beta are available for experimental purposes and may not be fully functional. We do not recommend relying on beta features for critical work at this time.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 9f15b2dd62c..bc9937ec56e 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -239,6 +239,40 @@ "TwoFactorAuthEnabled": "Two-factor authentication is enabled", "TwoFactorAuthDisabled": "Two-factor authentication is disabled", "ShowQRCode": "Show QR code", - "EnterVerificationCode": "Enter verification code" + "EnterVerificationCode": "Enter verification code", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again.", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create token", + "ApiTokenName": "Token name", + "ApiTokenExpiry": "Expiration", + "ApiTokenCreated": "Token created", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenWorkspace": "Workspace", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiUsageTitle": "Using the REST API", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiEndpointPing": "Health check", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointTx": "Create or update documents", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointAccount": "Get account info", + "ApiBaseUrl": "Base URL", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL." } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index 21a07261a83..84cf8e5b29c 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -230,6 +230,49 @@ "TwoFactorAuthEnabled": "La autenticación de dos factores está habilitada", "TwoFactorAuthDisabled": "La autenticación de dos factores está deshabilitada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Introducir código de verificación" + "EnterVerificationCode": "Introducir código de verificación", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Create token", + "Created": "Created", + "Description": "Description", + "Expires": "Expires", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index c703c28be31..7090548dcb2 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -239,6 +239,40 @@ "TwoFactorAuthEnabled": "L'authentification à deux facteurs est activée", "TwoFactorAuthDisabled": "L'authentification à deux facteurs est désactivée", "ShowQRCode": "Afficher le code QR", - "EnterVerificationCode": "Entrer le code de vérification" + "EnterVerificationCode": "Entrer le code de vérification", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index bb2ce1ec98c..9b01bc8a607 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -239,6 +239,40 @@ "TwoFactorAuthEnabled": "L'autenticazione a due fattori è abilitata", "TwoFactorAuthDisabled": "L'autenticazione a due fattori è disabilitata", "ShowQRCode": "Mostra codice QR", - "EnterVerificationCode": "Inserisci codice di verifica" + "EnterVerificationCode": "Inserisci codice di verifica", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index 9cbbdb74182..847b71f329d 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -239,6 +239,40 @@ "TwoFactorAuthEnabled": "二要素認証は有効です", "TwoFactorAuthDisabled": "二要素認証は無効です", "ShowQRCode": "QRコードを表示", - "EnterVerificationCode": "確認コードを入力" + "EnterVerificationCode": "確認コードを入力", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 8cd1eb4d95d..a0d605461b4 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -230,6 +230,49 @@ "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Inserir código de verificação" + "EnterVerificationCode": "Inserir código de verificação", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Create token", + "Created": "Created", + "Description": "Description", + "Expires": "Expires", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 0f12567fa7f..21719a03012 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -230,6 +230,49 @@ "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Inserir código de verificação" + "EnterVerificationCode": "Inserir código de verificação", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Create token", + "Created": "Created", + "Description": "Description", + "Expires": "Expires", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 616f9ad91a2..f2314e8c705 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -239,6 +239,40 @@ "TwoFactorAuthEnabled": "Двухфакторная аутентификация включена", "TwoFactorAuthDisabled": "Двухфакторная аутентификация отключена", "ShowQRCode": "Показать QR-код", - "EnterVerificationCode": "Введите код подтверждения" + "EnterVerificationCode": "Введите код подтверждения", + "ApiBaseUrl": "Базовый URL", + "ApiEndpointAccount": "Информация об аккаунте", + "ApiEndpointFindAll": "Запрос документов по классу", + "ApiEndpointFindAllPost": "Запрос с фильтрами (JSON body)", + "ApiEndpointLoadModel": "Загрузка модели данных", + "ApiEndpointPing": "Проверка состояния", + "ApiEndpointTx": "Создание или обновление документов", + "ApiTokenCopyWarning": "Скопируйте этот токен сейчас. Вы не сможете увидеть его снова.", + "ApiTokenCreated": "Токен создан", + "ApiTokenExpiry": "Срок действия", + "ApiTokenName": "Название токена", + "ApiTokenNoTokens": "API токенов пока нет", + "ApiTokenRevoke": "Отозвать токен", + "ApiTokenRevokeConfirm": "Вы уверены, что хотите отозвать этот токен? Он больше не будет доступен для API-доступа.", + "ApiTokenWorkspace": "Рабочее пространство", + "ApiTokenStatusActive": "Активен", + "ApiTokenStatusExpiring": "Истекает", + "ApiTokenStatusRevoked": "Отозван", + "ApiTokenStatusExpired": "Истёк", + "ApiTokenExpiry7Days": "7 дней", + "ApiTokenExpiry30Days": "30 дней", + "ApiTokenExpiry90Days": "90 дней", + "ApiTokenExpiry180Days": "180 дней", + "ApiTokenExpiry365Days": "365 дней", + "ApiTokenLoadError": "Не удалось загрузить API токены", + "ApiTokenCreateError": "Не удалось создать токен. Попробуйте ещё раз.", + "ApiTokens": "API токены", + "ApiUsageDescription": "Используйте API-токен для работы с встроенным REST API для запроса и изменения данных рабочего пространства. Передайте токен как Bearer-токен в заголовке Authorization.", + "ApiUsageTitle": "Использование REST API", + "ApiWorkspaceId": "Идентификатор вашего рабочего пространства (UUID) включён в токен. Передайте его как :workspaceId в URL.", + "CreateApiToken": "Создать токен", + "Created": "Создан", + "Expires": "Истекает", + "TokenStatus": "Статус" } } diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 3ff113b41e9..f014d110f00 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -239,6 +239,40 @@ "TwoFactorAuthEnabled": "İki faktörlü kimlik doğrulama etkin", "TwoFactorAuthDisabled": "İki faktörlü kimlik doğrulama devre dışı", "ShowQRCode": "QR kodu göster", - "EnterVerificationCode": "Doğrulama kodunu gir" + "EnterVerificationCode": "Doğrulama kodunu gir", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index 0783fae9ef1..dfb0d395e50 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -239,6 +239,40 @@ "TwoFactorAuthEnabled": "双因素认证已启用", "TwoFactorAuthDisabled": "双因素认证已禁用", "ShowQRCode": "显示QR码", - "EnterVerificationCode": "输入验证码" + "EnterVerificationCode": "输入验证码", + "ApiBaseUrl": "Base URL", + "ApiEndpointAccount": "Get account info", + "ApiEndpointFindAll": "Query documents by class", + "ApiEndpointFindAllPost": "Query with filters (JSON body)", + "ApiEndpointLoadModel": "Load the data model", + "ApiEndpointPing": "Health check", + "ApiEndpointTx": "Create or update documents", + "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", + "ApiTokenCreated": "Token created", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Token name", + "ApiTokenNoTokens": "No API tokens yet", + "ApiTokenRevoke": "Revoke token", + "ApiTokenRevokeConfirm": "Are you sure you want to revoke this token? It will no longer be usable for API access.", + "ApiTokenWorkspace": "Workspace", + "ApiTokens": "API Tokens", + "ApiUsageDescription": "Use your API token with the built-in REST API to query and modify workspace data. Pass the token as a Bearer token in the Authorization header.", + "ApiUsageTitle": "Using the REST API", + "ApiWorkspaceId": "Your workspace ID (UUID) is included in the token. Pass it as :workspaceId in the URL.", + "CreateApiToken": "Create token", + "Created": "Created", + "Expires": "Expires", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Active", + "ApiTokenStatusExpiring": "Expiring", + "ApiTokenStatusRevoked": "Revoked", + "ApiTokenStatusExpired": "Expired", + "ApiTokenExpiry7Days": "7 days", + "ApiTokenExpiry30Days": "30 days", + "ApiTokenExpiry90Days": "90 days", + "ApiTokenExpiry180Days": "180 days", + "ApiTokenExpiry365Days": "365 days", + "ApiTokenLoadError": "Failed to load API tokens", + "ApiTokenCreateError": "Failed to create token. Please try again." } } diff --git a/plugins/setting-assets/src/index.ts b/plugins/setting-assets/src/index.ts index 3b19b1f4a17..a91292bd042 100644 --- a/plugins/setting-assets/src/index.ts +++ b/plugins/setting-assets/src/index.ts @@ -37,5 +37,6 @@ loadMetadata(setting.icon, { Relations: `${icons}#relation`, Mailbox: `${icons}#mailbox`, OfficeSettings: `${icons}#office`, - Reset: `${icons}#reset` + Reset: `${icons}#reset`, + ApiToken: `${icons}#apiToken` }) diff --git a/plugins/setting-resources/src/components/ApiDocsSection.svelte b/plugins/setting-resources/src/components/ApiDocsSection.svelte new file mode 100644 index 00000000000..6c12d922e1e --- /dev/null +++ b/plugins/setting-resources/src/components/ApiDocsSection.svelte @@ -0,0 +1,240 @@ + + + +
+ + {#if showApiDocs} +
+

+ +
+ + copySnippet(baseApiUrl)} + on:keydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') copySnippet(baseApiUrl) + }}>{baseApiUrl} +
+ +

+ +
+
+
GET
+ /api/v1/ping/:workspaceId + +
+
+
GET
+ /api/v1/find-all/:workspaceId?class=... + +
+
+
POST
+ /api/v1/find-all/:workspaceId + +
+
+
POST
+ /api/v1/tx/:workspaceId + +
+
+
GET
+ /api/v1/load-model/:workspaceId + +
+
+
GET
+ /api/v1/account/:workspaceId + +
+
+ +
+ Example +
 copySnippet(curlExample)}
+          on:keydown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') copySnippet(curlExample)
+          }}>{curlExample}
+
+
+ {/if} +
+ + diff --git a/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte new file mode 100644 index 00000000000..d9a2e028b30 --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte @@ -0,0 +1,179 @@ + + + + { + dispatch('close', createdToken !== undefined) + }} +> + {#if createdToken !== undefined} +
+ +
{ + if (e.key === 'Enter' || e.key === ' ') copyToken() + }} + > + {createdToken} +
+
+ {:else} +
+ +
+
+ + +
+
+ + +
+ {#if error !== undefined} +
{error}
+ {/if} + {/if} +
+ + diff --git a/plugins/setting-resources/src/components/ApiTokens.svelte b/plugins/setting-resources/src/components/ApiTokens.svelte new file mode 100644 index 00000000000..f231d3fdb3f --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokens.svelte @@ -0,0 +1,204 @@ + + + +
+
+ + + + +
+
+
+ {#if loading} + + {:else if loadError} +
+
+ {:else if tokens.length === 0} +
+
+ {:else} + + + + + + + + + + + + + + {#each tokens as token} + {@const status = getStatus(token)} + + + + + + + + + {/each} + +
{token.name}{token.workspaceName}{formatDate(token.createdOn)}{token.revoked ? '—' : formatDate(token.expiresOn)} + + + + {#if !token.revoked} + { + revoke(token) + }} + /> + {/if} +
+
+ {/if} + + +
+
+
+ + diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 1e3571d89a7..a8d042f332d 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -74,6 +74,7 @@ import AddSocialId from './components/socialIds/AddSocialId.svelte' import AddEmailSocialId from './components/socialIds/AddEmailSocialId.svelte' import Mailboxes from './components/Mailboxes.svelte' import GuestPermissionsSettings from './components/GuestPermissionsSettings.svelte' +import ApiTokens from './components/ApiTokens.svelte' import OfficeSettings from './components/OfficeSettings.svelte' import BaseIntegrationState from './components/integrations/BaseIntegrationState.svelte' import IntegrationStateRow from './components/integrations/IntegrationStateRow.svelte' @@ -173,7 +174,8 @@ export default async (): Promise => ({ AddEmailSocialId, EmployeeRefEditor, UserRoleSelect, - TwoFactorSettings + TwoFactorSettings, + ApiTokens }, actionImpl: { DeleteMixin diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 98c09a0e87d..df8d5ed0727 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -200,7 +200,8 @@ export default plugin(settingId, { OfficeSettings: '' as Ref, DisablePermissionsConfiguration: '' as Ref, Mailboxes: '' as Ref, - Security: '' as Ref + Security: '' as Ref, + ApiTokens: '' as Ref }, mixin: { Editable: '' as Ref>, @@ -246,7 +247,8 @@ export default plugin(settingId, { AddEmailSocialId: '' as AnyComponent, OfficeSettings: '' as AnyComponent, UserRoleSelect: '' as AnyComponent, - TwoFactorSettings: '' as AnyComponent + TwoFactorSettings: '' as AnyComponent, + ApiTokens: '' as AnyComponent }, string: { Settings: '' as IntlString, @@ -353,7 +355,41 @@ export default plugin(settingId, { Disconnected: '' as IntlString, Available: '' as IntlString, NotConnectedIntegration: '' as IntlString, - IntegrationIsUnstable: '' as IntlString + IntegrationIsUnstable: '' as IntlString, + ApiTokenStatusActive: '' as IntlString, + ApiTokenStatusExpiring: '' as IntlString, + ApiTokenStatusRevoked: '' as IntlString, + ApiTokenStatusExpired: '' as IntlString, + ApiTokenExpiry7Days: '' as IntlString, + ApiTokenExpiry30Days: '' as IntlString, + ApiTokenExpiry90Days: '' as IntlString, + ApiTokenExpiry180Days: '' as IntlString, + ApiTokenExpiry365Days: '' as IntlString, + ApiTokenLoadError: '' as IntlString, + ApiTokenCreateError: '' as IntlString, + ApiTokens: '' as IntlString, + CreateApiToken: '' as IntlString, + ApiTokenName: '' as IntlString, + ApiTokenExpiry: '' as IntlString, + ApiTokenCreated: '' as IntlString, + ApiTokenRevoke: '' as IntlString, + ApiTokenRevokeConfirm: '' as IntlString, + ApiTokenCopyWarning: '' as IntlString, + ApiTokenNoTokens: '' as IntlString, + ApiTokenWorkspace: '' as IntlString, + Created: '' as IntlString, + Expires: '' as IntlString, + TokenStatus: '' as IntlString, + ApiUsageTitle: '' as IntlString, + ApiUsageDescription: '' as IntlString, + ApiEndpointPing: '' as IntlString, + ApiEndpointFindAll: '' as IntlString, + ApiEndpointFindAllPost: '' as IntlString, + ApiEndpointTx: '' as IntlString, + ApiEndpointLoadModel: '' as IntlString, + ApiEndpointAccount: '' as IntlString, + ApiBaseUrl: '' as IntlString, + ApiWorkspaceId: '' as IntlString }, icon: { AccountSettings: '' as Asset, @@ -375,7 +411,8 @@ export default plugin(settingId, { Relations: '' as Asset, Mailbox: '' as Asset, OfficeSettings: '' as Asset, - Reset: '' as Asset + Reset: '' as Asset, + ApiToken: '' as Asset }, templateFieldCategory: { Integration: '' as Ref diff --git a/server/account/src/collections/mongo.ts b/server/account/src/collections/mongo.ts index b4e5e5e7afe..0da5322ff66 100644 --- a/server/account/src/collections/mongo.ts +++ b/server/account/src/collections/mongo.ts @@ -58,7 +58,8 @@ import type { WorkspaceOperation, WorkspaceStatus, WorkspaceStatusData, - WorkspacePermission + WorkspacePermission, + ApiToken } from '../types' import { isShallowEqual } from '../utils' @@ -411,6 +412,7 @@ export class MongoAccountDB implements AccountDB { workspaceMembers: MongoDbCollection workspacePermission: MongoDbCollection + apiToken: MongoDbCollection constructor (readonly db: Db) { this.migration = new MongoDbCollection('migration', db, 'key') @@ -431,6 +433,7 @@ export class MongoAccountDB implements AccountDB { this.workspaceMembers = new MongoDbCollection('workspaceMembers', db) this.workspacePermission = new MongoDbCollection('workspacePermissions', db) + this.apiToken = new MongoDbCollection('apiTokens', db, 'id') } async init (): Promise { @@ -865,6 +868,7 @@ export class MongoAccountDB implements AccountDB { } await this.mailbox.deleteMany({ accountUuid }) + await this.apiToken.deleteMany({ accountUuid }) await this.socialId.update({ personUuid: accountUuid }, { verifiedOn: undefined }) await this.workspaceMembers.deleteMany({ accountUuid }) diff --git a/server/account/src/collections/postgres/migrations.ts b/server/account/src/collections/postgres/migrations.ts index adaef2de9c5..218eccb93f2 100644 --- a/server/account/src/collections/postgres/migrations.ts +++ b/server/account/src/collections/postgres/migrations.ts @@ -82,7 +82,8 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][] getV22Migration(ns, flavor), getV23Migration(ns, flavor), getV24Migration(ns, flavor), - getV25Migration(ns, flavor) + getV25Migration(ns, flavor), + getV26Migration(ns, flavor) ] } @@ -794,3 +795,34 @@ function getV25Migration (ns: string, flavor: DBFlavor): [string, string] { ` ] } + +function getV26Migration (ns: string, flavor: DBFlavor): [string, string] { + const types = dbTypes[flavor] + return [ + 'account_db_v26_add_api_tokens_table', + ` + /* ======= A P I T O K E N S ======= */ + CREATE TABLE IF NOT EXISTS ${ns}.api_tokens ( + id ${types.string} NOT NULL, + account_uuid UUID NOT NULL, + name ${types.string} NOT NULL, + workspace_uuid UUID NOT NULL, + created_on ${types.int8} NOT NULL DEFAULT current_epoch_ms(), + expires_on ${types.int8} NOT NULL, + revoked ${types.bool} NOT NULL DEFAULT false, + CONSTRAINT api_tokens_pk PRIMARY KEY (id), + CONSTRAINT api_tokens_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.person(uuid), + CONSTRAINT api_tokens_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid) + ); + + CREATE INDEX IF NOT EXISTS api_tokens_account_idx + ON ${ns}.api_tokens (account_uuid); + + CREATE INDEX IF NOT EXISTS api_tokens_workspace_idx + ON ${ns}.api_tokens (workspace_uuid); + + CREATE INDEX IF NOT EXISTS api_tokens_expires_on_idx + ON ${ns}.api_tokens (expires_on); + ` + ] +} diff --git a/server/account/src/collections/postgres/postgres.ts b/server/account/src/collections/postgres/postgres.ts index 4ab5aea2b14..84211c544d4 100644 --- a/server/account/src/collections/postgres/postgres.ts +++ b/server/account/src/collections/postgres/postgres.ts @@ -50,6 +50,7 @@ import type { UserProfile, Subscription, WorkspacePermission, + ApiToken, DBFlavor } from '../../types' @@ -540,6 +541,7 @@ export class PostgresAccountDB implements AccountDB { userProfile: PostgresDbCollection subscription: PostgresDbCollection workspacePermission: PostgresDbCollection + apiToken: PostgresDbCollection constructor ( readonly client: Sql, @@ -609,6 +611,12 @@ export class PostgresAccountDB implements AccountDB { timestampFields: ['createdOn'], withRetryClient }) + this.apiToken = new PostgresDbCollection('api_tokens', client, { + ns, + idKey: 'id', + timestampFields: ['createdOn', 'expiresOn'], + withRetryClient + }) } getWsMembersTableName (): string { @@ -1079,6 +1087,7 @@ export class PostgresAccountDB implements AccountDB { } await this.mailbox.deleteMany({ accountUuid }, rTx) + await this.apiToken.deleteMany({ accountUuid }, rTx) await this.socialId.update({ personUuid: accountUuid }, { verifiedOn: undefined }, rTx) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index a1869b41c9b..c353ebe36d6 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -38,6 +38,7 @@ import { import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' import { decodeToken, decodeTokenVerbose, generateToken, type PermissionsGrant } from '@hcengineering/server-token' +import { randomUUID } from 'crypto' import { isAdminEmail } from './admin' import { accountPlugin } from './plugin' import { type AccountServiceMethods, getServiceMethods } from './serviceOperations' @@ -2566,6 +2567,215 @@ async function deleteMailbox ( ctx.info('Mailbox deleted', { mailbox, account }) } +// ── API Token Management ──────────────────────────────────────────── + +/** + * Creates a new API token for the authenticated user. + * @param params.name Human-readable token name (1–255 chars) + * @param params.workspaceUuid Target workspace — user must have access + * @param params.expiryDays Token validity period (1–365 days) + * @returns Token ID, signed JWT, and expiration timestamp (ms) + * @throws BadRequest if validation fails + * @throws Forbidden if user lacks workspace access + */ +async function createApiToken ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + name: string + workspaceUuid: WorkspaceUuid + expiryDays: number + } +): Promise<{ id: string, token: string, expiresOn: number }> { + const { name, workspaceUuid, expiryDays } = params + + if ( + name == null || + typeof name !== 'string' || + name.trim() === '' || + name.trim().length > 255 || + workspaceUuid == null + ) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + if (typeof expiryDays !== 'number' || !Number.isFinite(expiryDays)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const days = Math.floor(expiryDays) + if (days < 1 || days > 365) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const { account } = decodeTokenVerbose(ctx, token) + + // Verify the user has access to this workspace + const role = await db.getWorkspaceRole(account, workspaceUuid) + if (role == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + // Enforce per-account token limit + const MAX_TOKENS_PER_ACCOUNT = 100 + const existingTokens = await db.apiToken.find({ accountUuid: account }) + if (existingTokens.length >= MAX_TOKENS_PER_ACCOUNT) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const now = Date.now() + const expiresOn = now + days * 86400000 + const expSec = Math.floor(expiresOn / 1000) + + const id = randomUUID() + const apiToken = generateToken(account, workspaceUuid, undefined, undefined, { exp: expSec }) + + await db.apiToken.insertOne({ + id, + accountUuid: account, + name, + workspaceUuid, + createdOn: now, + expiresOn, + revoked: false + }) + + ctx.info('API token created', { id, account, workspaceUuid, days }) + return { id, token: apiToken, expiresOn } +} + +/** + * Lists all API tokens for the authenticated user across all workspaces. + * Includes workspace names resolved from workspace UUIDs. + */ +async function listApiTokens ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string +): Promise< + Array<{ + id: string + name: string + workspaceUuid: WorkspaceUuid + workspaceName: string + createdOn: number + expiresOn: number + revoked: boolean + }> +> { + const { account } = decodeTokenVerbose(ctx, token) + + const tokens = await db.apiToken.find({ accountUuid: account }) + const wsUuids = [...new Set(tokens.map((t) => t.workspaceUuid))] + const workspaces = await db.workspace.find({ uuid: { $in: wsUuids } as any }) + const wsMap = new Map(workspaces.map((w) => [w.uuid, w.name ?? w.url])) + + return tokens.map((t) => ({ + id: t.id, + name: t.name, + workspaceUuid: t.workspaceUuid, + workspaceName: wsMap.get(t.workspaceUuid) ?? t.workspaceUuid, + createdOn: t.createdOn, + expiresOn: t.expiresOn, + revoked: t.revoked + })) +} + +/** + * Soft-revoke: marks the token as revoked in the DB. + * The JWT itself remains valid until expiry — full revocation requires + * a denylist check at the transactor level (future enhancement). + */ +async function revokeApiToken ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { tokenId: string } +): Promise { + const { account } = decodeTokenVerbose(ctx, token) + const { tokenId } = params + + const existing = await db.apiToken.findOne({ id: tokenId, accountUuid: account }) + if (existing == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + await db.apiToken.update({ id: tokenId }, { revoked: true }) + ctx.info('API token revoked', { tokenId, account }) +} + +/** + * Lists all API tokens for a workspace. Requires OWNER role. + * Returns tokens from all members, with account UUIDs for attribution. + */ +async function listWorkspaceApiTokens ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { workspaceUuid: WorkspaceUuid } +): Promise< + Array<{ + id: string + name: string + accountUuid: PersonUuid + workspaceUuid: WorkspaceUuid + createdOn: number + expiresOn: number + revoked: boolean + }> +> { + const { account } = decodeTokenVerbose(ctx, token) + const { workspaceUuid } = params + + const role = await db.getWorkspaceRole(account, workspaceUuid) + if (role == null || role !== AccountRole.Owner) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const tokens = await db.apiToken.find({ workspaceUuid }) + return tokens.map((t) => ({ + id: t.id, + name: t.name, + accountUuid: t.accountUuid, + workspaceUuid: t.workspaceUuid, + createdOn: t.createdOn, + expiresOn: t.expiresOn, + revoked: t.revoked + })) +} + +/** + * Revoke any token in the workspace. Requires OWNER role. + */ +async function revokeWorkspaceApiToken ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { tokenId: string, workspaceUuid: WorkspaceUuid } +): Promise { + const { account } = decodeTokenVerbose(ctx, token) + const { tokenId, workspaceUuid } = params + + const role = await db.getWorkspaceRole(account, workspaceUuid) + if (role == null || role !== AccountRole.Owner) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const existing = await db.apiToken.findOne({ id: tokenId, workspaceUuid }) + if (existing == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + await db.apiToken.update({ id: tokenId }, { revoked: true }) + ctx.info('Workspace API token revoked by owner', { tokenId, account, workspaceUuid }) +} + async function exchangeGuestToken ( ctx: MeasureContext, db: AccountDB, @@ -3264,6 +3474,11 @@ export type AccountMethods = | 'hasWorkspacePermission' | 'getWorkspacePermissions' | 'getWorkspaceUsersWithPermission' + | 'createApiToken' + | 'listApiTokens' + | 'revokeApiToken' + | 'listWorkspaceApiTokens' + | 'revokeWorkspaceApiToken' /** * @public @@ -3331,6 +3546,13 @@ export function getMethods (hasSignUp: boolean = true): Partial subscription: DbCollection workspacePermission: DbCollection + apiToken: DbCollection init: () => Promise createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise From 97be826a13667f1836ea39e32bd83d6be6b52c6c Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Tue, 17 Mar 2026 10:55:20 -0400 Subject: [PATCH 2/9] feat: enforce API token revocation at transactor level Embed apiTokenId in JWT extra field and add a per-token revocation cache (60s TTL) in the transactor REST handler. Revoked tokens are now rejected within ~60 seconds instead of remaining valid until JWT expiry. Adds checkApiTokenRevoked account service method for the transactor to query individual token revocation status. Signed-off-by: Don Kendall --- .../packages/account-client/src/client.ts | 10 +++++ pods/server/src/rpc.ts | 38 +++++++++++++++++++ server/account/src/operations.ts | 23 ++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 5d666f1c43a..5dfe0ac3b35 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -262,6 +262,7 @@ export interface AccountClient { revokeApiToken: (tokenId: string) => Promise listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise revokeWorkspaceApiToken: (tokenId: string, workspaceUuid: WorkspaceUuid) => Promise + checkApiTokenRevoked: (apiTokenId: string) => Promise setCookie: () => Promise deleteCookie: () => Promise @@ -1276,6 +1277,15 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } + async checkApiTokenRevoked (apiTokenId: string): Promise { + const request = { + method: 'checkApiTokenRevoked' as const, + params: { apiTokenId } + } + + return await this.rpc(request) + } + async setCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'PUT' }) diff --git a/pods/server/src/rpc.ts b/pods/server/src/rpc.ts index 4c147a4c096..f2575fcf124 100644 --- a/pods/server/src/rpc.ts +++ b/pods/server/src/rpc.ts @@ -129,6 +129,35 @@ async function sendJson ( res.end(body) } +// ── API Token Revocation Cache ────────────────────────────────────── +// Per-token cache with 60s TTL. Once a token is confirmed revoked it +// stays cached permanently (revocation is irreversible). Non-revoked +// tokens are re-checked every TTL interval. +const REVOCATION_CACHE_TTL_MS = 60_000 +const revocationCache = new Map() + +async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClient): Promise { + const now = Date.now() + const cached = revocationCache.get(apiTokenId) + + // Permanently cached once revoked + if (cached?.revoked === true) return true + + // Re-check if stale or missing + if (cached == null || now - cached.checkedAt > REVOCATION_CACHE_TTL_MS) { + try { + const revoked = await accountClient.checkApiTokenRevoked(apiTokenId) + revocationCache.set(apiTokenId, { revoked, checkedAt: now }) + return revoked + } catch { + // If we can't reach the account service, use stale cache or allow + return cached?.revoked ?? false + } + } + + return cached.revoked +} + export function registerRPC (app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void { const rpcSessions = new Map() @@ -167,6 +196,15 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur return } + // Reject revoked API tokens (cached check, ~60s TTL) + const apiTokenId = decodedToken.extra?.apiTokenId + if (apiTokenId !== undefined) { + if (await isApiTokenRevoked(apiTokenId, getAccountClient(token))) { + sendError(res, 401, { message: 'Token has been revoked' }) + return + } + } + let transactorRpc = rpcSessions.get(token) if (transactorRpc === undefined) { diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index c353ebe36d6..ea5ad8337e0 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -2630,7 +2630,7 @@ async function createApiToken ( const expSec = Math.floor(expiresOn / 1000) const id = randomUUID() - const apiToken = generateToken(account, workspaceUuid, undefined, undefined, { exp: expSec }) + const apiToken = generateToken(account, workspaceUuid, { apiTokenId: id }, undefined, { exp: expSec }) await db.apiToken.insertOne({ id, @@ -2708,6 +2708,25 @@ async function revokeApiToken ( ctx.info('API token revoked', { tokenId, account }) } +/** + * Checks if a specific API token has been revoked. + * Used by the transactor to enforce revocation at the request level. + */ +async function checkApiTokenRevoked ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { apiTokenId: string } +): Promise { + const { apiTokenId } = params + const existing = await db.apiToken.findOne({ id: apiTokenId }) + if (existing == null) { + return true // Unknown token treated as revoked + } + return existing.revoked +} + /** * Lists all API tokens for a workspace. Requires OWNER role. * Returns tokens from all members, with account UUIDs for attribution. @@ -3479,6 +3498,7 @@ export type AccountMethods = | 'revokeApiToken' | 'listWorkspaceApiTokens' | 'revokeWorkspaceApiToken' + | 'checkApiTokenRevoked' /** * @public @@ -3552,6 +3572,7 @@ export function getMethods (hasSignUp: boolean = true): Partial Date: Tue, 17 Mar 2026 12:07:20 -0400 Subject: [PATCH 3/9] feat: implement Phase 1 API token scopes (read/write/delete) Add coarse-grained scope enforcement for API tokens. Tokens can now be created with scopes ['read:*'], ['read:*','write:*'], or ['read:*','write:*','delete:*']. Existing tokens without scopes retain full access (backward compatible). - DB: v26 migration adds scopes TEXT[] column to api_tokens - Types: add scopes field to ApiToken and ApiTokenInfo - Operations: createApiToken accepts/validates/persists scopes, embeds in JWT via extra.scopes - Enforcement: withSession checks scopes against method; tx handler additionally requires delete:* for TxRemoveDoc - Client: createApiToken signature accepts optional scopes param - UI: scope preset dropdown in create popup (default: Read Only), permissions column in token list with i18n labels - Also fixes 3 pre-existing TS2322/TS2345 errors in operations.ts Signed-off-by: Don Kendall --- .../packages/account-client/src/client.ts | 6 +-- .../core/packages/account-client/src/types.ts | 1 + plugins/setting-assets/lang/en.json | 5 ++ .../src/components/ApiTokenCreatePopup.svelte | 38 ++++++++++++- .../src/components/ApiTokens.svelte | 35 ++++++++++++ plugins/setting/src/index.ts | 5 ++ pods/server/src/rpc.ts | 53 ++++++++++++++++++- .../src/collections/postgres/migrations.ts | 10 ++++ server/account/src/operations.ts | 40 ++++++++++---- server/account/src/types.ts | 11 ++++ 10 files changed, 190 insertions(+), 14 deletions(-) diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 5dfe0ac3b35..c439d365abd 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -257,7 +257,7 @@ export interface AccountClient { getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise verify2fa: (code: string) => Promise - createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number) => Promise + createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number, scopes?: string[]) => Promise listApiTokens: () => Promise revokeApiToken: (tokenId: string) => Promise listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise @@ -1232,10 +1232,10 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } - async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number): Promise { + async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number, scopes?: string[]): Promise { const request = { method: 'createApiToken' as const, - params: { name, workspaceUuid, expiryDays } + params: { name, workspaceUuid, expiryDays, scopes } } return await this.rpc(request) diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index ac7c757d5e8..82dfae11c05 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -120,6 +120,7 @@ export interface ApiTokenInfo { createdOn: number expiresOn: number revoked: boolean + scopes?: string[] } export interface ApiTokenResult { diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index bc9937ec56e..eaaeb85ae21 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -261,6 +261,11 @@ "ApiTokenCopyWarning": "Copy this token now. You won't be able to see it again.", "ApiTokenNoTokens": "No API tokens yet", "ApiTokenWorkspace": "Workspace", + "ApiTokenPermissions": "Permissions", + "ApiTokenScopePreset": "Permissions", + "ApiTokenScopeReadOnly": "Read Only", + "ApiTokenScopeReadWrite": "Read & Write", + "ApiTokenScopeFullAccess": "Full Access", "Created": "Created", "Expires": "Expires", "TokenStatus": "Status", diff --git a/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte index d9a2e028b30..5b8ccef0021 100644 --- a/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte +++ b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte @@ -32,6 +32,35 @@ let copiedTime: Timestamp | undefined let copied = false + const scopePresets = [ + { _id: 'read-only', label: 'Read Only', scopes: ['read:*'] }, + { _id: 'read-write', label: 'Read & Write', scopes: ['read:*', 'write:*'] }, + { _id: 'full-access', label: 'Full Access', scopes: ['read:*', 'write:*', 'delete:*'] } + ] + let scopePresetItems: ListItem[] = scopePresets.map((p) => ({ _id: p._id, label: p.label })) + let selectedScopePreset: ListItem = scopePresetItems[0] + + async function resolveScopeLabels (): Promise { + const lang = $themeStore.language + const labels = await Promise.all([ + translate(setting.string.ApiTokenScopeReadOnly, {}, lang), + translate(setting.string.ApiTokenScopeReadWrite, {}, lang), + translate(setting.string.ApiTokenScopeFullAccess, {}, lang) + ]) + const prevId = selectedScopePreset._id + scopePresetItems = [ + { _id: 'read-only', label: labels[0] }, + { _id: 'read-write', label: labels[1] }, + { _id: 'full-access', label: labels[2] } + ] + selectedScopePreset = scopePresetItems.find((o) => o._id === prevId) ?? scopePresetItems[0] + } + + function getSelectedScopes (): string[] { + const preset = scopePresets.find((p) => p._id === selectedScopePreset._id) + return preset?.scopes ?? ['read:*'] + } + const expiryKeys = [ { _id: '7', intl: setting.string.ApiTokenExpiry7Days }, { _id: '30', intl: setting.string.ApiTokenExpiry30Days }, @@ -72,10 +101,12 @@ loading = true error = undefined try { + const scopes = getSelectedScopes() const result = await getAccountClient().createApiToken( name.trim(), selectedWs._id as WorkspaceUuid, - parseInt(selectedExpiry._id, 10) + parseInt(selectedExpiry._id, 10), + scopes ) createdToken = result.token } catch (err: any) { @@ -99,6 +130,7 @@ onMount(() => { void loadWorkspaces() void resolveExpiryLabels() + void resolveScopeLabels() }) @@ -139,6 +171,10 @@ +
+ + +
diff --git a/plugins/setting-resources/src/components/ApiTokens.svelte b/plugins/setting-resources/src/components/ApiTokens.svelte index f231d3fdb3f..1370ddf69dd 100644 --- a/plugins/setting-resources/src/components/ApiTokens.svelte +++ b/plugins/setting-resources/src/components/ApiTokens.svelte @@ -15,6 +15,7 @@ @@ -132,6 +161,7 @@
- +
diff --git a/plugins/setting-resources/src/components/ApiTokens.svelte b/plugins/setting-resources/src/components/ApiTokens.svelte index 1370ddf69dd..2ed85eec179 100644 --- a/plugins/setting-resources/src/components/ApiTokens.svelte +++ b/plugins/setting-resources/src/components/ApiTokens.svelte @@ -35,7 +35,7 @@ expired: setting.string.ApiTokenStatusExpired } as const - function loadTokens (): void { + function loadTokens(): void { loading = true loadError = false getAccountClient() @@ -52,7 +52,7 @@ }) } - function create (): void { + function create(): void { showPopup(ApiTokenCreatePopup, {}, 'top', (res) => { if (res === true) { loadTokens() @@ -60,7 +60,7 @@ }) } - function revoke (token: ApiTokenInfo): void { + function revoke(token: ApiTokenInfo): void { showPopup(MessageBox, { label: setting.string.ApiTokenRevoke, message: setting.string.ApiTokenRevokeConfirm, @@ -76,7 +76,7 @@ }) } - function formatDate (ts: number): string { + function formatDate(ts: number): string { return new Date(ts).toLocaleDateString($themeStore.language ?? 'en', { month: 'short', day: 'numeric', @@ -90,7 +90,7 @@ fullAccess: 'Full Access' } - async function resolveScopeLabels (): Promise { + async function resolveScopeLabels(): Promise { const lang = $themeStore.language scopeLabels = { readOnly: await translate(setting.string.ApiTokenScopeReadOnly, {}, lang), @@ -99,7 +99,7 @@ } } - function getScopeLabel (token: ApiTokenInfo): string { + function getScopeLabel(token: ApiTokenInfo): string { const scopes = token.scopes if (scopes == null || scopes.length === 0) return scopeLabels.fullAccess const hasRead = scopes.includes('read:*') @@ -111,7 +111,7 @@ return `${scopes.length} scopes` } - function getStatus (token: ApiTokenInfo): 'active' | 'expiring' | 'revoked' | 'expired' { + function getStatus(token: ApiTokenInfo): 'active' | 'expiring' | 'revoked' | 'expired' { if (token.revoked) return 'revoked' const now = Date.now() if (token.expiresOn < now) return 'expired' diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index a8d042f332d..269a4c0ced6 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -100,7 +100,7 @@ export { IntegrationStateRow } -async function DeleteMixin (object: Mixin>): Promise { +async function DeleteMixin(object: Mixin>): Promise { const docs = await getClient().findAll(object._id, {}, { limit: 1 }) showPopup(MessageBox, { diff --git a/pods/server/src/rpc.ts b/pods/server/src/rpc.ts index 3419f673935..b5bcc3a7217 100644 --- a/pods/server/src/rpc.ts +++ b/pods/server/src/rpc.ts @@ -63,7 +63,7 @@ const sendError = (res: ExpressResponse, code: number, data: any): void => { res.end(JSON.stringify(data)) } -function rateLimitToHeaders (rateLimit?: RateLimitInfo): OutgoingHttpHeaders { +function rateLimitToHeaders(rateLimit?: RateLimitInfo): OutgoingHttpHeaders { if (rateLimit === undefined) { return {} } @@ -77,7 +77,7 @@ function rateLimitToHeaders (rateLimit?: RateLimitInfo): OutgoingHttpHeaders { } } -async function sendJson ( +async function sendJson( req: Request, res: ExpressResponse, result: any, @@ -134,9 +134,9 @@ async function sendJson ( // stays cached permanently (revocation is irreversible). Non-revoked // tokens are re-checked every TTL interval. const REVOCATION_CACHE_TTL_MS = 60_000 -const revocationCache = new Map() +const revocationCache = new Map() -async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClient): Promise { +async function isApiTokenRevoked(apiTokenId: string, accountClient: AccountClient): Promise { const now = Date.now() const cached = revocationCache.get(apiTokenId) @@ -161,11 +161,11 @@ async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClie // ── Token Scope Enforcement ───────────────────────────────────────── // Phase 1: coarse scopes only (read:*, write:*, delete:*) -export function hasScope (scopes: string[], required: string): boolean { +export function hasScope(scopes: string[], required: string): boolean { return scopes.includes(required) } -export function getRequiredScope (method: string): string | null { +export function getRequiredScope(method: string): string | null { switch (method) { case 'ping': case 'generateId': @@ -186,14 +186,14 @@ export function getRequiredScope (method: string): string | null { } } -export function registerRPC (app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void { +export function registerRPC(app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void { const rpcSessions = new Map() - function getAccountClient (token?: string): AccountClient { + function getAccountClient(token?: string): AccountClient { return getAccountClientRaw(accountsUrl, token) } - async function withSession ( + async function withSession( req: Request, res: ExpressResponse, method: string, @@ -551,7 +551,7 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur }) } -function createClosingSocket (rawToken: string, rpcSessions: Map): ConnectionSocket { +function createClosingSocket(rawToken: string, rpcSessions: Map): ConnectionSocket { return { id: rawToken, isClosed: false, diff --git a/server/account/src/__tests__/apiTokenScopes.test.ts b/server/account/src/__tests__/apiTokenScopes.test.ts index fb2bb672891..34fa5f5647e 100644 --- a/server/account/src/__tests__/apiTokenScopes.test.ts +++ b/server/account/src/__tests__/apiTokenScopes.test.ts @@ -13,13 +13,7 @@ // limitations under the License. // -import { - AccountRole, - type MeasureContext, - type PersonUuid, - type WorkspaceUuid -} from '@hcengineering/core' -import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' +import { AccountRole, type MeasureContext, type PersonUuid, type WorkspaceUuid } from '@hcengineering/core' import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token' import { type AccountDB } from '../types' @@ -75,6 +69,7 @@ describe('createApiToken scopes', () => { } as unknown as AccountDB const methods = getMethods() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const createApiToken = methods.createApiToken! beforeEach(() => { @@ -91,7 +86,9 @@ describe('createApiToken scopes', () => { test('creates token with valid scopes', async () => { const result = await createApiToken( - mockCtx, mockDb, null, + mockCtx, + mockDb, + null, { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:*'] } }, 'test-token' ) @@ -110,14 +107,14 @@ describe('createApiToken scopes', () => { ) // Verify scopes were persisted to DB - expect(mockDb.apiToken.insertOne).toHaveBeenCalledWith( - expect.objectContaining({ scopes: ['read:*'] }) - ) + expect(mockDb.apiToken.insertOne).toHaveBeenCalledWith(expect.objectContaining({ scopes: ['read:*'] })) }) test('creates token with multiple valid scopes', async () => { const result = await createApiToken( - mockCtx, mockDb, null, + mockCtx, + mockDb, + null, { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:*', 'write:*', 'delete:*'] } }, 'test-token' ) @@ -130,7 +127,9 @@ describe('createApiToken scopes', () => { test('creates token without scopes (full access, backward compat)', async () => { const result = await createApiToken( - mockCtx, mockDb, null, + mockCtx, + mockDb, + null, { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30 } }, 'test-token' ) @@ -153,7 +152,9 @@ describe('createApiToken scopes', () => { test('rejects invalid scope format', async () => { const result = await createApiToken( - mockCtx, mockDb, null, + mockCtx, + mockDb, + null, { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['invalid'] } }, 'test-token' ) @@ -163,7 +164,9 @@ describe('createApiToken scopes', () => { test('rejects empty scopes array', async () => { const result = await createApiToken( - mockCtx, mockDb, null, + mockCtx, + mockDb, + null, { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: [] } }, 'test-token' ) @@ -173,7 +176,9 @@ describe('createApiToken scopes', () => { test('rejects domain-scoped scopes in Phase 1', async () => { const result = await createApiToken( - mockCtx, mockDb, null, + mockCtx, + mockDb, + null, { id: 1, params: { name: 'test', workspaceUuid, expiryDays: 30, scopes: ['read:tracker'] } }, 'test-token' ) @@ -223,6 +228,7 @@ describe('listApiTokens includes scopes', () => { } as unknown as AccountDB const methods = getMethods() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const listApiTokens = methods.listApiTokens! beforeEach(() => { @@ -235,11 +241,7 @@ describe('listApiTokens includes scopes', () => { }) test('returns scopes for scoped tokens and undefined for legacy', async () => { - const result = await listApiTokens( - mockCtx, mockDb, null, - { id: 1, params: {} }, - 'test-token' - ) + const result = await listApiTokens(mockCtx, mockDb, null, { id: 1, params: {} }, 'test-token') const tokens = result.result expect(tokens).toHaveLength(2) diff --git a/server/account/src/collections/postgres/migrations.ts b/server/account/src/collections/postgres/migrations.ts index 1f7b3ba4304..0f4cc8238f7 100644 --- a/server/account/src/collections/postgres/migrations.ts +++ b/server/account/src/collections/postgres/migrations.ts @@ -83,7 +83,8 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][] getV23Migration(ns, flavor), getV24Migration(ns, flavor), getV25Migration(ns, flavor), - getV26Migration(ns, flavor) + getV26Migration(ns, flavor), + getV27Migration(ns, flavor) ] } @@ -827,9 +828,9 @@ function getV26Migration (ns: string, flavor: DBFlavor): [string, string] { ] } -function getV26Migration (ns: string, flavor: DBFlavor): [string, string] { +function getV27Migration (ns: string, flavor: DBFlavor): [string, string] { return [ - 'account_db_v26_add_api_token_scopes', + 'account_db_v27_add_api_token_scopes', ` ALTER TABLE ${ns}.api_tokens ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT NULL; diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index f22a0e8ddd2..4ef96b28e8d 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -35,7 +35,7 @@ import { type WorkspaceUuid, type IntegrationKind } from '@hcengineering/core' -import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' +import platform, { PlatformError, Severity, Status, getMetadata, translate } from '@hcengineering/platform' import { decodeToken, decodeTokenVerbose, generateToken, type PermissionsGrant } from '@hcengineering/server-token' import { randomUUID } from 'crypto' @@ -2612,13 +2612,14 @@ async function createApiToken ( throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) } - const { account } = decodeTokenVerbose(ctx, token) + const { account, extra } = decodeTokenVerbose(ctx, token) - // Verify the user has access to this workspace + // Verify the user has access to this workspace and is at least a User (not a guest) const role = await db.getWorkspaceRole(account, workspaceUuid) if (role == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } + verifyAllowedRole(role, AccountRole.User, extra) // Enforce per-account token limit const MAX_TOKENS_PER_ACCOUNT = 100 @@ -2645,11 +2646,11 @@ async function createApiToken ( const expSec = Math.floor(expiresOn / 1000) const id = randomUUID() - const extra: Record = { apiTokenId: id } + const tokenExtra: Record = { apiTokenId: id } if (scopes !== undefined) { - extra.scopes = JSON.stringify(scopes) + tokenExtra.scopes = JSON.stringify(scopes) } - const apiToken = generateToken(account, workspaceUuid, extra, undefined, { exp: expSec }) + const apiToken = generateToken(account, workspaceUuid, tokenExtra, undefined, { exp: expSec }) await db.apiToken.insertOne({ id, @@ -2685,7 +2686,7 @@ async function listApiTokens ( expiresOn: number revoked: boolean }> -> { + > { const { account } = decodeTokenVerbose(ctx, token) const tokens = await db.apiToken.find({ accountUuid: account }) @@ -2717,7 +2718,7 @@ async function revokeApiToken ( token: string, params: { tokenId: string } ): Promise { - const { account } = decodeTokenVerbose(ctx, token) + const { account, extra } = decodeTokenVerbose(ctx, token) const { tokenId } = params const existing = await db.apiToken.findOne({ id: tokenId, accountUuid: account }) @@ -2725,6 +2726,10 @@ async function revokeApiToken ( throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) } + // Verify the user is at least a User (not a guest) in the token's workspace + const role = await db.getWorkspaceRole(account, existing.workspaceUuid) + verifyAllowedRole(role, AccountRole.User, extra) + await db.apiToken.update({ id: tokenId }, { revoked: true }) ctx.info('API token revoked', { tokenId, account }) } @@ -2768,7 +2773,7 @@ async function listWorkspaceApiTokens ( expiresOn: number revoked: boolean }> -> { + > { const { account } = decodeTokenVerbose(ctx, token) const { workspaceUuid } = params From bd680ca5485d7605d1f72a196112104d966581a4 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 18 Apr 2026 15:04:42 -0400 Subject: [PATCH 6/9] fix: address aonnikov architectural review feedback - rpc.ts: use system service token for checkApiTokenRevoked so the revocation check is not coupled to the user's potentially-revoked bearer token; systemAccountUuid + service:'server' ensures account service always accepts the call - ApiDocsSection.svelte: derive transactor base URL from login.metadata.LoginEndpoint (set on auth) instead of window.location.origin, which is not necessarily the transactor host - ApiTokenCreatePopup.svelte: replace manual translate() calls and themeStore language watch with DropdownLabelsIntl + DropdownIntlItem[], which handle i18n automatically; error state is now IntlString - General.svelte: remove legacy GenerateApiToken button, handler, and ApiTokenPopup import in favour of the new ApiTokens settings panel Signed-off-by: Don Kendall --- .../src/components/ApiDocsSection.svelte | 11 +- .../src/components/ApiTokenCreatePopup.svelte | 104 ++++++++---------- .../src/components/General.svelte | 33 ++---- pods/server/src/rpc.ts | 9 +- 4 files changed, 68 insertions(+), 89 deletions(-) diff --git a/plugins/setting-resources/src/components/ApiDocsSection.svelte b/plugins/setting-resources/src/components/ApiDocsSection.svelte index 53ac4f85a6a..8408a17c2a7 100644 --- a/plugins/setting-resources/src/components/ApiDocsSection.svelte +++ b/plugins/setting-resources/src/components/ApiDocsSection.svelte @@ -13,17 +13,18 @@ // limitations under the License. --> @@ -173,18 +153,30 @@
- { + selectedScopePreset = e.detail + }} />
- + { + selectedExpiry = e.detail + }} + />
{#if error !== undefined} -
{error}
+
{/if} {/if} diff --git a/plugins/setting-resources/src/components/General.svelte b/plugins/setting-resources/src/components/General.svelte index 9f1e4bc367b..169a33aa443 100644 --- a/plugins/setting-resources/src/components/General.svelte +++ b/plugins/setting-resources/src/components/General.svelte @@ -43,7 +43,6 @@ Toggle } from '@hcengineering/ui' import settingsRes from '../plugin' - import ApiTokenPopup from './ApiTokenPopup.svelte' import WorkspacePermissionEditor from './WorkspacePermissionEditor.svelte' let loading = true @@ -65,7 +64,7 @@ void loadWorkspaceName() - async function loadWorkspaceName (): Promise { + async function loadWorkspaceName(): Promise { const res = await accountClient.getWorkspaceInfo() workspaceUrl = res.url @@ -75,7 +74,7 @@ loading = false } - async function handleEditName (): Promise { + async function handleEditName(): Promise { if (editNameDisabled) { return } @@ -87,12 +86,12 @@ isEditingName = !isEditingName } - function handleCancelEditName (): void { + function handleCancelEditName(): void { name = oldName isEditingName = false } - async function handleDelete (): Promise { + async function handleDelete(): Promise { showPopup(MessageBox, { label: settingsRes.string.DeleteWorkspace, message: settingsRes.string.DeleteWorkspaceConfirm, @@ -113,7 +112,7 @@ workspaceSettings = r }) - async function handleAvatarDone (): Promise { + async function handleAvatarDone(): Promise { const existing = await client.findOne(settingsRes.class.WorkspaceSetting, { _id: settingsRes.ids.WorkspaceSetting }) if (existing !== undefined) { const avatar = await avatarEditor.createAvatar() @@ -148,17 +147,12 @@ } ) - async function changePasswordAgingRules (val: number | undefined): Promise { + async function changePasswordAgingRules(val: number | undefined): Promise { passwordAgingRule = Math.max(val ?? 1, 1) await accountClient.updatePasswordAgingRule(passwordAgingRule) } - async function handleGenerateApiToken (): Promise { - const { token } = await accountClient.selectWorkspace(workspaceUrl) - showPopup(ApiTokenPopup, { token }) - } - - function handleTogglePermissions (): void { + function handleTogglePermissions(): void { const newState = !arePermissionsDisabled showPopup(MessageBox, { label: newState ? settingsRes.string.DisablePermissions : settingsRes.string.EnablePermissions, @@ -318,19 +312,6 @@ allowGuests={true} /> -
-
-
-
-
-
diff --git a/pods/server/src/rpc.ts b/pods/server/src/rpc.ts index b5bcc3a7217..69ad271b219 100644 --- a/pods/server/src/rpc.ts +++ b/pods/server/src/rpc.ts @@ -27,7 +27,7 @@ import core, { } from '@hcengineering/core' import { rpcJSONReplacer, type RateLimitInfo } from '@hcengineering/rpc' import type { ClientSessionCtx, ConnectionSocket, Session, SessionManager } from '@hcengineering/server-core' -import { decodeToken } from '@hcengineering/server-token' +import { decodeToken, generateToken } from '@hcengineering/server-token' import { createHash } from 'crypto' import { type Express, type Response as ExpressResponse, type Request } from 'express' @@ -227,7 +227,12 @@ export function registerRPC(app: Express, sessions: SessionManager, ctx: Measure // Reject revoked API tokens (cached check, ~60s TTL) const apiTokenId = decodedToken.extra?.apiTokenId if (apiTokenId !== undefined) { - if (await isApiTokenRevoked(apiTokenId, getAccountClient(token))) { + if ( + await isApiTokenRevoked( + apiTokenId, + getAccountClient(generateToken(systemAccountUuid, undefined, { service: 'server' })) + ) + ) { sendError(res, 401, { message: 'Token has been revoked' }) return } From 97ac92a710884c0a3a0910d248e16dd7dd12988f Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 18 Apr 2026 15:05:10 -0400 Subject: [PATCH 7/9] fix: formatting in ApiTokenPopup, apiTokenScopes test, and operations Signed-off-by: Don Kendall --- .../src/components/ApiTokenPopup.svelte | 2 +- .../src/__tests__/apiTokenScopes.test.ts | 2 +- server/account/src/operations.ts | 240 +++++++++--------- 3 files changed, 122 insertions(+), 122 deletions(-) diff --git a/plugins/setting-resources/src/components/ApiTokenPopup.svelte b/plugins/setting-resources/src/components/ApiTokenPopup.svelte index 7a4df4f4bc7..296447d7fb3 100644 --- a/plugins/setting-resources/src/components/ApiTokenPopup.svelte +++ b/plugins/setting-resources/src/components/ApiTokenPopup.svelte @@ -33,7 +33,7 @@ copied = false } - async function copy (): Promise { + async function copy(): Promise { if (!isSecureContext) return if (token === undefined) return diff --git a/server/account/src/__tests__/apiTokenScopes.test.ts b/server/account/src/__tests__/apiTokenScopes.test.ts index 34fa5f5647e..b22fe2d48ab 100644 --- a/server/account/src/__tests__/apiTokenScopes.test.ts +++ b/server/account/src/__tests__/apiTokenScopes.test.ts @@ -31,7 +31,7 @@ jest.mock('@hcengineering/platform', () => { jest.mock('@hcengineering/server-token', () => { class TokenError extends Error { - constructor (msg: string) { + constructor(msg: string) { super(msg) this.name = 'TokenError' } diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 4ef96b28e8d..5de921805b1 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -146,7 +146,7 @@ const workspaceLimitPerUser = /** * Given an email and password, logs the user in and returns the account information and token. */ -export async function loginAsGuest ( +export async function loginAsGuest( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -167,7 +167,7 @@ export async function loginAsGuest ( * If the account has too many failed login attempts, password login is blocked. * The user must use an alternative method (e.g., OTP) to unlock the account. */ -export async function login ( +export async function login( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -237,10 +237,10 @@ export async function login ( account: existingAccount.uuid, token: isConfirmed ? generateToken( - existingAccount.tfaSecret != null ? NIL_UUID : existingAccount.uuid, - undefined, - existingAccount.tfaSecret != null ? { ...extraToken, tfaAccount: existingAccount.uuid } : extraToken - ) + existingAccount.tfaSecret != null ? NIL_UUID : existingAccount.uuid, + undefined, + existingAccount.tfaSecret != null ? { ...extraToken, tfaAccount: existingAccount.uuid } : extraToken + ) : undefined, name: getPersonName(person), socialId: emailSocialId._id, @@ -256,7 +256,7 @@ export async function login ( /** * Given an email sends an OTP code to the existing user and returns the OTP information. */ -export async function loginOtp ( +export async function loginOtp( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -292,7 +292,7 @@ export async function loginOtp ( * * ---------DEPRECATED. Only to be used for dev setups without mail service. Use signUpOtp instead. */ -export async function signUp ( +export async function signUp( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -338,7 +338,7 @@ export async function signUp ( } } -export async function signUpOtp ( +export async function signUpOtp( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -385,7 +385,7 @@ export async function signUpOtp ( /** * Validates email OTP for login/sign up/new social id */ -export async function validateOtp ( +export async function validateOtp( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -529,10 +529,10 @@ export async function validateOtp ( const _token = isConfirmed ? generateToken( - targetAccount?.tfaSecret != null ? NIL_UUID : emailSocialId.personUuid, - undefined, - targetAccount?.tfaSecret != null ? { ...extraToken, tfaAccount: emailSocialId.personUuid } : extraToken - ) + targetAccount?.tfaSecret != null ? NIL_UUID : emailSocialId.personUuid, + undefined, + targetAccount?.tfaSecret != null ? { ...extraToken, tfaAccount: emailSocialId.personUuid } : extraToken + ) : undefined return { @@ -549,7 +549,7 @@ export async function validateOtp ( } } -export async function createWorkspace ( +export async function createWorkspace( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -615,7 +615,7 @@ export async function createWorkspace ( } } -export async function createInvite ( +export async function createInvite( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -669,14 +669,14 @@ export async function createInvite ( // TODO: Temporary solution to prevent spam using sendInvite const invitesSend = new Map< -string, -{ - lastSend: number - totalSend: number -} + string, + { + lastSend: number + totalSend: number + } >() -export async function sendInvite ( +export async function sendInvite( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -716,7 +716,7 @@ export async function sendInvite ( ctx.info('Invite has been sent', { to: inviteEmail.to, workspaceUuid: workspace.uuid, workspaceName: workspace.name }) } -export async function createAccessLink ( +export async function createAccessLink( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -817,7 +817,7 @@ export async function createAccessLink ( } } -export async function createInviteLink ( +export async function createInviteLink( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -891,7 +891,7 @@ export async function createInviteLink ( return link } -function checkRateLimit (email: string, workspaceName: string): void { +function checkRateLimit(email: string, workspaceName: string): void { const now = Date.now() const lastInvites = invitesSend.get(email) if (lastInvites !== undefined) { @@ -919,7 +919,7 @@ function checkRateLimit (email: string, workspaceName: string): void { } } -export async function resendInvite ( +export async function resendInvite( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -981,7 +981,7 @@ export async function resendInvite ( * If already a member, updates the role if necessary. * Returns the workspace login information. */ -export async function join ( +export async function join( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1022,7 +1022,7 @@ export async function join ( * Returns public invite details (e.g. workspace name) for a valid invite. No auth required. * Returns { workspaceName: null } for invalid or expired invites. */ -export async function getInviteInfo ( +export async function getInviteInfo( ctx: MeasureContext, db: AccountDB, _branding: Branding | null, @@ -1054,7 +1054,7 @@ export async function getInviteInfo ( * Given an invite and a token, checks if the user has already joined the workspace and updates the role if necessary. * Returns the workspace login information if the user has already joined. Otherwise, throws an error. */ -export async function checkJoin ( +export async function checkJoin( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1103,7 +1103,7 @@ export async function checkJoin ( * Joins the workspace using the current session token (no password). * Called only when the user explicitly clicks "Join with this account". */ -export async function joinByToken ( +export async function joinByToken( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1139,12 +1139,12 @@ export async function joinByToken ( return await doJoinByInvite(ctx, db, branding, token, accountUuid, workspace, invite) } -export async function checkAutoJoin ( +export async function checkAutoJoin( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { inviteId: string, firstName?: string, lastName?: string } + params: { inviteId: string; firstName?: string; lastName?: string } ): Promise { const { inviteId, firstName, lastName } = params @@ -1255,7 +1255,7 @@ export async function checkAutoJoin ( /** * Given an invite and sign up information, creates an account and assigns it to the workspace. */ -export async function signUpJoin ( +export async function signUpJoin( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1299,7 +1299,7 @@ export async function signUpJoin ( ) } -export async function confirm ( +export async function confirm( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1338,7 +1338,7 @@ export async function confirm ( * Checks whether the authenticated account has a password set. * SSO-only accounts (Google, GitHub, OIDC) have no password hash. */ -export async function checkHasPassword ( +export async function checkHasPassword( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1352,7 +1352,7 @@ export async function checkHasPassword ( return account.hash != null && account.salt != null } -export async function changePassword ( +export async function changePassword( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1387,7 +1387,7 @@ export async function changePassword ( ctx.info('Password changed', { accountUuid }) } -export async function requestPasswordReset ( +export async function requestPasswordReset( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1466,7 +1466,7 @@ export async function requestPasswordReset ( * Requires authentication (session token). Only valid for accounts that have * no password set — accounts with an existing password must use changePassword. */ -export async function requestPasswordSetup ( +export async function requestPasswordSetup( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1525,7 +1525,7 @@ export async function requestPasswordSetup ( } } -export async function restorePassword ( +export async function restorePassword( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1565,7 +1565,7 @@ export async function restorePassword ( return await login(ctx, db, branding, token, { email, password }) } -export async function leaveWorkspace ( +export async function leaveWorkspace( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1643,7 +1643,7 @@ export async function leaveWorkspace ( return null } -export async function changeUsername ( +export async function changeUsername( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1666,7 +1666,7 @@ export async function changeUsername ( ctx.info('Person name changed', { account, first, last }) } -export async function updateWorkspaceName ( +export async function updateWorkspaceName( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1695,7 +1695,7 @@ export async function updateWorkspaceName ( ) } -export async function deleteWorkspace ( +export async function deleteWorkspace( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1718,12 +1718,12 @@ export async function deleteWorkspace ( ) } -export async function generate2faSecret ( +export async function generate2faSecret( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string -): Promise<{ secret: string, url: string }> { +): Promise<{ secret: string; url: string }> { const { account: accountUuid } = decodeTokenVerbose(ctx, token) const account = await getAccount(db, accountUuid) if (account == null) { @@ -1742,12 +1742,12 @@ export async function generate2faSecret ( return { secret, url } } -export async function enable2fa ( +export async function enable2fa( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { secret: string, code: string } + params: { secret: string; code: string } ): Promise { const { secret, code } = params const { account: accountUuid } = decodeTokenVerbose(ctx, token) @@ -1760,7 +1760,7 @@ export async function enable2fa ( ctx.info('2FA enabled', { accountUuid }) } -export async function disable2fa ( +export async function disable2fa( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1783,7 +1783,7 @@ export async function disable2fa ( ctx.info('2FA disabled', { accountUuid }) } -export async function verify2fa ( +export async function verify2fa( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1828,7 +1828,7 @@ export async function verify2fa ( /* ==========READ OPERATIONS========== */ /* =================================== */ -export async function getRegionInfo ( +export async function getRegionInfo( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1837,7 +1837,7 @@ export async function getRegionInfo ( return getRegions() } -export async function getUserWorkspaces ( +export async function getUserWorkspaces( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1853,7 +1853,7 @@ export async function getUserWorkspaces ( /** * @public */ -export async function getWorkspacesInfo ( +export async function getWorkspacesInfo( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1882,7 +1882,7 @@ export async function getWorkspacesInfo ( /** * @public */ -export async function updateLastVisit ( +export async function updateLastVisit( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1905,7 +1905,7 @@ export async function updateLastVisit ( await db.workspaceStatus.update({ workspaceUuid: { $in: ids } }, { lastVisit: Date.now() }) } -export async function getWorkspaceInfo ( +export async function getWorkspaceInfo( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1954,7 +1954,7 @@ export async function getWorkspaceInfo ( /** * Validates the token and returns the decoded account information. */ -export async function getLoginInfoByToken ( +export async function getLoginInfoByToken( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2153,7 +2153,7 @@ export async function getLoginInfoByToken ( /** * Validates the token and returns the decoded account information. */ -export async function getLoginWithWorkspaceInfo ( +export async function getLoginWithWorkspaceInfo( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2224,23 +2224,23 @@ export async function getLoginWithWorkspaceInfo ( isSystem || isDocGuest ? [] : userWorkspaces.map((it, idx) => [ - it.uuid, - { - url: it.url, - dataId: it.dataId, - mode: it.status.mode, - endpoint: getWorkspaceEndpoint(info, it.uuid, it.region), - role: roles.get(it.uuid) ?? null, - version: { - versionMajor: it.status.versionMajor, - versionMinor: it.status.versionMinor, - versionPatch: it.status.versionPatch - }, - progress: it.status.processingProgress, - branding: it.branding, - passwordAgingRule: it.passwordAgingRule - } - ]) + it.uuid, + { + url: it.url, + dataId: it.dataId, + mode: it.status.mode, + endpoint: getWorkspaceEndpoint(info, it.uuid, it.region), + role: roles.get(it.uuid) ?? null, + version: { + versionMajor: it.status.versionMajor, + versionMinor: it.status.versionMinor, + versionPatch: it.status.versionPatch + }, + progress: it.status.processingProgress, + branding: it.branding, + passwordAgingRule: it.passwordAgingRule + } + ]) ), socialIds } @@ -2255,12 +2255,12 @@ export async function getLoginWithWorkspaceInfo ( return loginInfo } -export async function getSocialIds ( +export async function getSocialIds( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { confirmed: boolean, includeDeleted: boolean } + params: { confirmed: boolean; includeDeleted: boolean } ): Promise { const { confirmed = true, includeDeleted = false } = params const { account: accountUuid, sub } = decodeTokenVerbose(ctx, token) @@ -2276,7 +2276,7 @@ export async function getSocialIds ( return includeDeleted ? socialIds : socialIds.filter((si) => si.isDeleted !== true) } -export async function isReadOnlyGuest ( +export async function isReadOnlyGuest( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2286,7 +2286,7 @@ export async function isReadOnlyGuest ( return account === readOnlyGuestAccountUuid } -export async function getPerson ( +export async function getPerson( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2303,12 +2303,12 @@ export async function getPerson ( return person } -export async function findPersonBySocialId ( +export async function findPersonBySocialId( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { socialId: PersonId, requireAccount?: boolean } + params: { socialId: PersonId; requireAccount?: boolean } ): Promise { const { socialId, requireAccount } = params @@ -2335,12 +2335,12 @@ export async function findPersonBySocialId ( return socialIdObj.personUuid } -export async function findSocialIdBySocialKey ( +export async function findSocialIdBySocialKey( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { socialKey: string, requireAccount?: boolean } + params: { socialKey: string; requireAccount?: boolean } ): Promise { const { socialKey, requireAccount } = params decodeTokenVerbose(ctx, token) @@ -2366,7 +2366,7 @@ export async function findSocialIdBySocialKey ( return socialIdObj._id } -export async function getWorkspaceMembers ( +export async function getWorkspaceMembers( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2387,7 +2387,7 @@ export async function getWorkspaceMembers ( return await db.getWorkspaceMembers(workspace) } -export async function getAccountInfo ( +export async function getAccountInfo( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2407,7 +2407,7 @@ export async function getAccountInfo ( return { timezone: account?.timezone, locale: account?.locale, tfaEnabled: account?.tfaSecret != null } } -export async function ensurePerson ( +export async function ensurePerson( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2418,7 +2418,7 @@ export async function ensurePerson ( firstName: string lastName: string } -): Promise<{ uuid: PersonUuid, socialId: PersonId }> { +): Promise<{ uuid: PersonUuid; socialId: PersonId }> { const { account, workspace, extra } = decodeTokenVerbose(ctx, token) const allowedService = verifyAllowedServices( ['tool', 'workspace', 'schedule', 'mail', 'github', 'hulygram'], @@ -2451,7 +2451,7 @@ export async function ensurePerson ( return { uuid: personUuid, socialId: newSocialId } } -async function getMailboxOptions ( +async function getMailboxOptions( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2467,7 +2467,7 @@ async function getMailboxOptions ( } } -async function createMailbox ( +async function createMailbox( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2476,7 +2476,7 @@ async function createMailbox ( name: string domain: string } -): Promise<{ mailbox: string, socialId: PersonId }> { +): Promise<{ mailbox: string; socialId: PersonId }> { const { name, domain } = params if (name == null || name === '' || domain == null || domain === '') { @@ -2514,7 +2514,7 @@ async function createMailbox ( return { mailbox, socialId } } -async function getMailboxes ( +async function getMailboxes( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2524,7 +2524,7 @@ async function getMailboxes ( return await db.mailbox.find({ accountUuid: account }) } -async function getMailboxSecret ( +async function getMailboxSecret( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2538,7 +2538,7 @@ async function getMailboxSecret ( return await db.mailboxSecret.findOne({ mailbox: params.mailbox }) } -async function deleteMailbox ( +async function deleteMailbox( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2579,7 +2579,7 @@ async function deleteMailbox ( * @throws BadRequest if validation fails * @throws Forbidden if user lacks workspace access */ -async function createApiToken ( +async function createApiToken( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2590,7 +2590,7 @@ async function createApiToken ( expiryDays: number scopes?: string[] } -): Promise<{ id: string, token: string, expiresOn: number }> { +): Promise<{ id: string; token: string; expiresOn: number }> { const { name, workspaceUuid, expiryDays, scopes } = params if ( @@ -2671,7 +2671,7 @@ async function createApiToken ( * Lists all API tokens for the authenticated user across all workspaces. * Includes workspace names resolved from workspace UUIDs. */ -async function listApiTokens ( +async function listApiTokens( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2686,7 +2686,7 @@ async function listApiTokens ( expiresOn: number revoked: boolean }> - > { +> { const { account } = decodeTokenVerbose(ctx, token) const tokens = await db.apiToken.find({ accountUuid: account }) @@ -2711,7 +2711,7 @@ async function listApiTokens ( * The JWT itself remains valid until expiry — full revocation requires * a denylist check at the transactor level (future enhancement). */ -async function revokeApiToken ( +async function revokeApiToken( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2738,7 +2738,7 @@ async function revokeApiToken ( * Checks if a specific API token has been revoked. * Used by the transactor to enforce revocation at the request level. */ -async function checkApiTokenRevoked ( +async function checkApiTokenRevoked( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2757,7 +2757,7 @@ async function checkApiTokenRevoked ( * Lists all API tokens for a workspace. Requires OWNER role. * Returns tokens from all members, with account UUIDs for attribution. */ -async function listWorkspaceApiTokens ( +async function listWorkspaceApiTokens( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2773,7 +2773,7 @@ async function listWorkspaceApiTokens ( expiresOn: number revoked: boolean }> - > { +> { const { account } = decodeTokenVerbose(ctx, token) const { workspaceUuid } = params @@ -2798,12 +2798,12 @@ async function listWorkspaceApiTokens ( /** * Revoke any token in the workspace. Requires OWNER role. */ -async function revokeWorkspaceApiToken ( +async function revokeWorkspaceApiToken( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { tokenId: string, workspaceUuid: WorkspaceUuid } + params: { tokenId: string; workspaceUuid: WorkspaceUuid } ): Promise { const { account } = decodeTokenVerbose(ctx, token) const { tokenId, workspaceUuid } = params @@ -2822,7 +2822,7 @@ async function revokeWorkspaceApiToken ( ctx.info('Workspace API token revoked by owner', { tokenId, account, workspaceUuid }) } -async function exchangeGuestToken ( +async function exchangeGuestToken( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2852,7 +2852,7 @@ async function exchangeGuestToken ( return token } -async function addEmailSocialId ( +async function addEmailSocialId( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2903,7 +2903,7 @@ async function addEmailSocialId ( return await sendOtp(ctx, db, branding, targetSocialId) } -async function addHulyAssistantSocialId ( +async function addHulyAssistantSocialId( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2914,7 +2914,7 @@ async function addHulyAssistantSocialId ( return await addSocialIdBase(db, account, SocialIdType.HULY_ASSISTANT, account, true) } -export async function refreshHulyAssistantToken ( +export async function refreshHulyAssistantToken( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2954,12 +2954,12 @@ export async function refreshHulyAssistantToken ( } } -export async function releaseSocialId ( +export async function releaseSocialId( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { personUuid?: PersonUuid, type: SocialIdType, value: string, deleteIntegrations?: boolean } + params: { personUuid?: PersonUuid; type: SocialIdType; value: string; deleteIntegrations?: boolean } ): Promise { const { account, extra } = decodeTokenVerbose(ctx, token) let { personUuid } = params @@ -3001,7 +3001,7 @@ export async function releaseSocialId ( return await doReleaseSocialId(db, personUuid, type, value, extra?.service ?? account, deleteIntegrations) } -export async function deleteAccount ( +export async function deleteAccount( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3030,7 +3030,7 @@ export async function deleteAccount ( }) } -export async function canMergeSpecifiedPersons ( +export async function canMergeSpecifiedPersons( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3071,7 +3071,7 @@ export async function canMergeSpecifiedPersons ( return verifiedSecondaryIds.length === 0 } -export async function mergeSpecifiedPersons ( +export async function mergeSpecifiedPersons( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3091,7 +3091,7 @@ export async function mergeSpecifiedPersons ( await doMergePersons(db, primaryPerson, secondaryPerson) } -async function setMyProfile ( +async function setMyProfile( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3141,7 +3141,7 @@ async function setMyProfile ( } } -async function getUserProfile ( +async function getUserProfile( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3208,7 +3208,7 @@ async function getUserProfile ( * By default returns only active subscriptions. Set activeOnly=false to include historical subscriptions. * @public */ -export async function getSubscriptions ( +export async function getSubscriptions( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3267,7 +3267,7 @@ export async function getSubscriptions ( * - Regular users: Can only query subscriptions from their workspace (from token) * @public */ -export async function getSubscriptionById ( +export async function getSubscriptionById( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3320,7 +3320,7 @@ export async function getSubscriptionById ( return subscription } -export async function batchAssignWorkspacePermission ( +export async function batchAssignWorkspacePermission( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3345,7 +3345,7 @@ export async function batchAssignWorkspacePermission ( await db.batchAssignWorkspacePermission(workspace, accountIds, permission) } -export async function batchRevokeWorkspacePermission ( +export async function batchRevokeWorkspacePermission( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3370,7 +3370,7 @@ export async function batchRevokeWorkspacePermission ( await db.batchRevokeWorkspacePermission(workspace, accountIds, permission) } -export async function hasWorkspacePermission ( +export async function hasWorkspacePermission( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3390,7 +3390,7 @@ export async function hasWorkspacePermission ( return await db.hasWorkspacePermission(accountId, workspace, permission) } -export async function getWorkspacePermissions ( +export async function getWorkspacePermissions( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3419,7 +3419,7 @@ export async function getWorkspacePermissions ( return await db.getWorkspacePermissions(accountId, permission) } -export async function getWorkspaceUsersWithPermission ( +export async function getWorkspaceUsersWithPermission( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3530,7 +3530,7 @@ export type AccountMethods = /** * @public */ -export function getMethods (hasSignUp: boolean = true): Partial> { +export function getMethods(hasSignUp: boolean = true): Partial> { return { /* OPERATIONS */ login: wrap(login), From 8e6aea7f57a53429606571e6dfeff75d1606cf05 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Thu, 23 Apr 2026 15:42:44 -0400 Subject: [PATCH 8/9] fix: apply rushx fmt to pass CI formatting check Signed-off-by: Don Kendall --- models/setting/src/index.ts | 4 +- .../src/components/ApiDocsSection.svelte | 2 +- .../src/components/ApiTokenCreatePopup.svelte | 8 +- .../src/components/ApiTokenPopup.svelte | 2 +- .../src/components/ApiTokens.svelte | 14 +- .../src/components/General.svelte | 14 +- plugins/setting-resources/src/index.ts | 2 +- pods/server/src/rpc.ts | 20 +- pods/server/src/server_http.ts | 8 +- .../src/__tests__/apiTokenScopes.test.ts | 2 +- server/account/src/operations.ts | 240 +++++++++--------- 11 files changed, 157 insertions(+), 159 deletions(-) diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 2045e714d7e..24237d3afc7 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -121,7 +121,7 @@ export class TInviteSettings extends TConfiguration implements InviteSettings { @UX(setting.string.RoleCapabilitySettings) export class TRoleCapabilitySettings extends TConfiguration implements RoleCapabilitySettings { @Prop(TypeRecord(), setting.string.RoleCapabilitySettings) - roleByCapability!: Record + roleByCapability!: Record } @Model(setting.class.OfficeSettings, core.class.Configuration, DOMAIN_SETTING) @@ -147,7 +147,7 @@ export class TSpaceTypeCreator extends TClass implements SpaceTypeCreator { extraComponent!: AnyComponent } -export function createModel(builder: Builder): void { +export function createModel (builder: Builder): void { builder.createModel( TIntegration, TIntegrationType, diff --git a/plugins/setting-resources/src/components/ApiDocsSection.svelte b/plugins/setting-resources/src/components/ApiDocsSection.svelte index 8408a17c2a7..34b8c168623 100644 --- a/plugins/setting-resources/src/components/ApiDocsSection.svelte +++ b/plugins/setting-resources/src/components/ApiDocsSection.svelte @@ -27,7 +27,7 @@ let baseApiUrl: string $: curlExample = `curl -H "Authorization: Bearer YOUR_TOKEN" \\\n "${baseApiUrl}/find-all/WORKSPACE_ID?class=tracker:class:Project"` - async function copySnippet(text: string): Promise { + async function copySnippet (text: string): Promise { await copyTextToClipboard(text) } diff --git a/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte index e2303ca7682..f59d5105c4a 100644 --- a/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte +++ b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte @@ -52,7 +52,7 @@ } let selectedScopePreset: string = 'read-only' - function getSelectedScopes(): string[] { + function getSelectedScopes (): string[] { return scopeScopes[selectedScopePreset] ?? ['read:*'] } @@ -73,7 +73,7 @@ copied = false } - async function loadWorkspaces(): Promise { + async function loadWorkspaces (): Promise { const workspaces = await getAccountClient().getUserWorkspaces() wsItems = workspaces.map((w) => ({ _id: w.uuid, label: w.name ?? w.url })) if (wsItems.length > 0) { @@ -81,7 +81,7 @@ } } - async function create(): Promise { + async function create (): Promise { if (selectedWs === undefined) return loading = true error = undefined @@ -102,7 +102,7 @@ } } - async function copyToken(): Promise { + async function copyToken (): Promise { if (createdToken === undefined || !window.isSecureContext) return await copyTextToClipboard(createdToken) copied = true diff --git a/plugins/setting-resources/src/components/ApiTokenPopup.svelte b/plugins/setting-resources/src/components/ApiTokenPopup.svelte index 296447d7fb3..7a4df4f4bc7 100644 --- a/plugins/setting-resources/src/components/ApiTokenPopup.svelte +++ b/plugins/setting-resources/src/components/ApiTokenPopup.svelte @@ -33,7 +33,7 @@ copied = false } - async function copy(): Promise { + async function copy (): Promise { if (!isSecureContext) return if (token === undefined) return diff --git a/plugins/setting-resources/src/components/ApiTokens.svelte b/plugins/setting-resources/src/components/ApiTokens.svelte index 2ed85eec179..1370ddf69dd 100644 --- a/plugins/setting-resources/src/components/ApiTokens.svelte +++ b/plugins/setting-resources/src/components/ApiTokens.svelte @@ -35,7 +35,7 @@ expired: setting.string.ApiTokenStatusExpired } as const - function loadTokens(): void { + function loadTokens (): void { loading = true loadError = false getAccountClient() @@ -52,7 +52,7 @@ }) } - function create(): void { + function create (): void { showPopup(ApiTokenCreatePopup, {}, 'top', (res) => { if (res === true) { loadTokens() @@ -60,7 +60,7 @@ }) } - function revoke(token: ApiTokenInfo): void { + function revoke (token: ApiTokenInfo): void { showPopup(MessageBox, { label: setting.string.ApiTokenRevoke, message: setting.string.ApiTokenRevokeConfirm, @@ -76,7 +76,7 @@ }) } - function formatDate(ts: number): string { + function formatDate (ts: number): string { return new Date(ts).toLocaleDateString($themeStore.language ?? 'en', { month: 'short', day: 'numeric', @@ -90,7 +90,7 @@ fullAccess: 'Full Access' } - async function resolveScopeLabels(): Promise { + async function resolveScopeLabels (): Promise { const lang = $themeStore.language scopeLabels = { readOnly: await translate(setting.string.ApiTokenScopeReadOnly, {}, lang), @@ -99,7 +99,7 @@ } } - function getScopeLabel(token: ApiTokenInfo): string { + function getScopeLabel (token: ApiTokenInfo): string { const scopes = token.scopes if (scopes == null || scopes.length === 0) return scopeLabels.fullAccess const hasRead = scopes.includes('read:*') @@ -111,7 +111,7 @@ return `${scopes.length} scopes` } - function getStatus(token: ApiTokenInfo): 'active' | 'expiring' | 'revoked' | 'expired' { + function getStatus (token: ApiTokenInfo): 'active' | 'expiring' | 'revoked' | 'expired' { if (token.revoked) return 'revoked' const now = Date.now() if (token.expiresOn < now) return 'expired' diff --git a/plugins/setting-resources/src/components/General.svelte b/plugins/setting-resources/src/components/General.svelte index 169a33aa443..f4865baccc2 100644 --- a/plugins/setting-resources/src/components/General.svelte +++ b/plugins/setting-resources/src/components/General.svelte @@ -64,7 +64,7 @@ void loadWorkspaceName() - async function loadWorkspaceName(): Promise { + async function loadWorkspaceName (): Promise { const res = await accountClient.getWorkspaceInfo() workspaceUrl = res.url @@ -74,7 +74,7 @@ loading = false } - async function handleEditName(): Promise { + async function handleEditName (): Promise { if (editNameDisabled) { return } @@ -86,12 +86,12 @@ isEditingName = !isEditingName } - function handleCancelEditName(): void { + function handleCancelEditName (): void { name = oldName isEditingName = false } - async function handleDelete(): Promise { + async function handleDelete (): Promise { showPopup(MessageBox, { label: settingsRes.string.DeleteWorkspace, message: settingsRes.string.DeleteWorkspaceConfirm, @@ -112,7 +112,7 @@ workspaceSettings = r }) - async function handleAvatarDone(): Promise { + async function handleAvatarDone (): Promise { const existing = await client.findOne(settingsRes.class.WorkspaceSetting, { _id: settingsRes.ids.WorkspaceSetting }) if (existing !== undefined) { const avatar = await avatarEditor.createAvatar() @@ -147,12 +147,12 @@ } ) - async function changePasswordAgingRules(val: number | undefined): Promise { + async function changePasswordAgingRules (val: number | undefined): Promise { passwordAgingRule = Math.max(val ?? 1, 1) await accountClient.updatePasswordAgingRule(passwordAgingRule) } - function handleTogglePermissions(): void { + function handleTogglePermissions (): void { const newState = !arePermissionsDisabled showPopup(MessageBox, { label: newState ? settingsRes.string.DisablePermissions : settingsRes.string.EnablePermissions, diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 269a4c0ced6..a8d042f332d 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -100,7 +100,7 @@ export { IntegrationStateRow } -async function DeleteMixin(object: Mixin>): Promise { +async function DeleteMixin (object: Mixin>): Promise { const docs = await getClient().findAll(object._id, {}, { limit: 1 }) showPopup(MessageBox, { diff --git a/pods/server/src/rpc.ts b/pods/server/src/rpc.ts index 69ad271b219..47917e56a63 100644 --- a/pods/server/src/rpc.ts +++ b/pods/server/src/rpc.ts @@ -63,7 +63,7 @@ const sendError = (res: ExpressResponse, code: number, data: any): void => { res.end(JSON.stringify(data)) } -function rateLimitToHeaders(rateLimit?: RateLimitInfo): OutgoingHttpHeaders { +function rateLimitToHeaders (rateLimit?: RateLimitInfo): OutgoingHttpHeaders { if (rateLimit === undefined) { return {} } @@ -77,7 +77,7 @@ function rateLimitToHeaders(rateLimit?: RateLimitInfo): OutgoingHttpHeaders { } } -async function sendJson( +async function sendJson ( req: Request, res: ExpressResponse, result: any, @@ -134,9 +134,9 @@ async function sendJson( // stays cached permanently (revocation is irreversible). Non-revoked // tokens are re-checked every TTL interval. const REVOCATION_CACHE_TTL_MS = 60_000 -const revocationCache = new Map() +const revocationCache = new Map() -async function isApiTokenRevoked(apiTokenId: string, accountClient: AccountClient): Promise { +async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClient): Promise { const now = Date.now() const cached = revocationCache.get(apiTokenId) @@ -161,11 +161,11 @@ async function isApiTokenRevoked(apiTokenId: string, accountClient: AccountClien // ── Token Scope Enforcement ───────────────────────────────────────── // Phase 1: coarse scopes only (read:*, write:*, delete:*) -export function hasScope(scopes: string[], required: string): boolean { +export function hasScope (scopes: string[], required: string): boolean { return scopes.includes(required) } -export function getRequiredScope(method: string): string | null { +export function getRequiredScope (method: string): string | null { switch (method) { case 'ping': case 'generateId': @@ -186,14 +186,14 @@ export function getRequiredScope(method: string): string | null { } } -export function registerRPC(app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void { +export function registerRPC (app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void { const rpcSessions = new Map() - function getAccountClient(token?: string): AccountClient { + function getAccountClient (token?: string): AccountClient { return getAccountClientRaw(accountsUrl, token) } - async function withSession( + async function withSession ( req: Request, res: ExpressResponse, method: string, @@ -556,7 +556,7 @@ export function registerRPC(app: Express, sessions: SessionManager, ctx: Measure }) } -function createClosingSocket(rawToken: string, rpcSessions: Map): ConnectionSocket { +function createClosingSocket (rawToken: string, rpcSessions: Map): ConnectionSocket { return { id: rawToken, isClosed: false, diff --git a/pods/server/src/server_http.ts b/pods/server/src/server_http.ts index c1469b8ae8a..6c238795891 100644 --- a/pods/server/src/server_http.ts +++ b/pods/server/src/server_http.ts @@ -515,11 +515,9 @@ export function startHttpServer ( }, 1000) } if ('upgrade' in s) { - void cs - .send(ctx, { id: -1, result: { state: 'upgrading', stats: (s as any).upgradeInfo } }, false, false) - .then(() => { - cs.close() - }) + void cs.send(ctx, { id: -1, result: { state: 'upgrading', stats: s.upgradeInfo } }, false, false).then(() => { + cs.close() + }) } }) void webSocketData.session.catch((err) => { diff --git a/server/account/src/__tests__/apiTokenScopes.test.ts b/server/account/src/__tests__/apiTokenScopes.test.ts index b22fe2d48ab..34fa5f5647e 100644 --- a/server/account/src/__tests__/apiTokenScopes.test.ts +++ b/server/account/src/__tests__/apiTokenScopes.test.ts @@ -31,7 +31,7 @@ jest.mock('@hcengineering/platform', () => { jest.mock('@hcengineering/server-token', () => { class TokenError extends Error { - constructor(msg: string) { + constructor (msg: string) { super(msg) this.name = 'TokenError' } diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 5de921805b1..4ef96b28e8d 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -146,7 +146,7 @@ const workspaceLimitPerUser = /** * Given an email and password, logs the user in and returns the account information and token. */ -export async function loginAsGuest( +export async function loginAsGuest ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -167,7 +167,7 @@ export async function loginAsGuest( * If the account has too many failed login attempts, password login is blocked. * The user must use an alternative method (e.g., OTP) to unlock the account. */ -export async function login( +export async function login ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -237,10 +237,10 @@ export async function login( account: existingAccount.uuid, token: isConfirmed ? generateToken( - existingAccount.tfaSecret != null ? NIL_UUID : existingAccount.uuid, - undefined, - existingAccount.tfaSecret != null ? { ...extraToken, tfaAccount: existingAccount.uuid } : extraToken - ) + existingAccount.tfaSecret != null ? NIL_UUID : existingAccount.uuid, + undefined, + existingAccount.tfaSecret != null ? { ...extraToken, tfaAccount: existingAccount.uuid } : extraToken + ) : undefined, name: getPersonName(person), socialId: emailSocialId._id, @@ -256,7 +256,7 @@ export async function login( /** * Given an email sends an OTP code to the existing user and returns the OTP information. */ -export async function loginOtp( +export async function loginOtp ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -292,7 +292,7 @@ export async function loginOtp( * * ---------DEPRECATED. Only to be used for dev setups without mail service. Use signUpOtp instead. */ -export async function signUp( +export async function signUp ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -338,7 +338,7 @@ export async function signUp( } } -export async function signUpOtp( +export async function signUpOtp ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -385,7 +385,7 @@ export async function signUpOtp( /** * Validates email OTP for login/sign up/new social id */ -export async function validateOtp( +export async function validateOtp ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -529,10 +529,10 @@ export async function validateOtp( const _token = isConfirmed ? generateToken( - targetAccount?.tfaSecret != null ? NIL_UUID : emailSocialId.personUuid, - undefined, - targetAccount?.tfaSecret != null ? { ...extraToken, tfaAccount: emailSocialId.personUuid } : extraToken - ) + targetAccount?.tfaSecret != null ? NIL_UUID : emailSocialId.personUuid, + undefined, + targetAccount?.tfaSecret != null ? { ...extraToken, tfaAccount: emailSocialId.personUuid } : extraToken + ) : undefined return { @@ -549,7 +549,7 @@ export async function validateOtp( } } -export async function createWorkspace( +export async function createWorkspace ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -615,7 +615,7 @@ export async function createWorkspace( } } -export async function createInvite( +export async function createInvite ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -669,14 +669,14 @@ export async function createInvite( // TODO: Temporary solution to prevent spam using sendInvite const invitesSend = new Map< - string, - { - lastSend: number - totalSend: number - } +string, +{ + lastSend: number + totalSend: number +} >() -export async function sendInvite( +export async function sendInvite ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -716,7 +716,7 @@ export async function sendInvite( ctx.info('Invite has been sent', { to: inviteEmail.to, workspaceUuid: workspace.uuid, workspaceName: workspace.name }) } -export async function createAccessLink( +export async function createAccessLink ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -817,7 +817,7 @@ export async function createAccessLink( } } -export async function createInviteLink( +export async function createInviteLink ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -891,7 +891,7 @@ export async function createInviteLink( return link } -function checkRateLimit(email: string, workspaceName: string): void { +function checkRateLimit (email: string, workspaceName: string): void { const now = Date.now() const lastInvites = invitesSend.get(email) if (lastInvites !== undefined) { @@ -919,7 +919,7 @@ function checkRateLimit(email: string, workspaceName: string): void { } } -export async function resendInvite( +export async function resendInvite ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -981,7 +981,7 @@ export async function resendInvite( * If already a member, updates the role if necessary. * Returns the workspace login information. */ -export async function join( +export async function join ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1022,7 +1022,7 @@ export async function join( * Returns public invite details (e.g. workspace name) for a valid invite. No auth required. * Returns { workspaceName: null } for invalid or expired invites. */ -export async function getInviteInfo( +export async function getInviteInfo ( ctx: MeasureContext, db: AccountDB, _branding: Branding | null, @@ -1054,7 +1054,7 @@ export async function getInviteInfo( * Given an invite and a token, checks if the user has already joined the workspace and updates the role if necessary. * Returns the workspace login information if the user has already joined. Otherwise, throws an error. */ -export async function checkJoin( +export async function checkJoin ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1103,7 +1103,7 @@ export async function checkJoin( * Joins the workspace using the current session token (no password). * Called only when the user explicitly clicks "Join with this account". */ -export async function joinByToken( +export async function joinByToken ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1139,12 +1139,12 @@ export async function joinByToken( return await doJoinByInvite(ctx, db, branding, token, accountUuid, workspace, invite) } -export async function checkAutoJoin( +export async function checkAutoJoin ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { inviteId: string; firstName?: string; lastName?: string } + params: { inviteId: string, firstName?: string, lastName?: string } ): Promise { const { inviteId, firstName, lastName } = params @@ -1255,7 +1255,7 @@ export async function checkAutoJoin( /** * Given an invite and sign up information, creates an account and assigns it to the workspace. */ -export async function signUpJoin( +export async function signUpJoin ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1299,7 +1299,7 @@ export async function signUpJoin( ) } -export async function confirm( +export async function confirm ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1338,7 +1338,7 @@ export async function confirm( * Checks whether the authenticated account has a password set. * SSO-only accounts (Google, GitHub, OIDC) have no password hash. */ -export async function checkHasPassword( +export async function checkHasPassword ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1352,7 +1352,7 @@ export async function checkHasPassword( return account.hash != null && account.salt != null } -export async function changePassword( +export async function changePassword ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1387,7 +1387,7 @@ export async function changePassword( ctx.info('Password changed', { accountUuid }) } -export async function requestPasswordReset( +export async function requestPasswordReset ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1466,7 +1466,7 @@ export async function requestPasswordReset( * Requires authentication (session token). Only valid for accounts that have * no password set — accounts with an existing password must use changePassword. */ -export async function requestPasswordSetup( +export async function requestPasswordSetup ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1525,7 +1525,7 @@ export async function requestPasswordSetup( } } -export async function restorePassword( +export async function restorePassword ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1565,7 +1565,7 @@ export async function restorePassword( return await login(ctx, db, branding, token, { email, password }) } -export async function leaveWorkspace( +export async function leaveWorkspace ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1643,7 +1643,7 @@ export async function leaveWorkspace( return null } -export async function changeUsername( +export async function changeUsername ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1666,7 +1666,7 @@ export async function changeUsername( ctx.info('Person name changed', { account, first, last }) } -export async function updateWorkspaceName( +export async function updateWorkspaceName ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1695,7 +1695,7 @@ export async function updateWorkspaceName( ) } -export async function deleteWorkspace( +export async function deleteWorkspace ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1718,12 +1718,12 @@ export async function deleteWorkspace( ) } -export async function generate2faSecret( +export async function generate2faSecret ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string -): Promise<{ secret: string; url: string }> { +): Promise<{ secret: string, url: string }> { const { account: accountUuid } = decodeTokenVerbose(ctx, token) const account = await getAccount(db, accountUuid) if (account == null) { @@ -1742,12 +1742,12 @@ export async function generate2faSecret( return { secret, url } } -export async function enable2fa( +export async function enable2fa ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { secret: string; code: string } + params: { secret: string, code: string } ): Promise { const { secret, code } = params const { account: accountUuid } = decodeTokenVerbose(ctx, token) @@ -1760,7 +1760,7 @@ export async function enable2fa( ctx.info('2FA enabled', { accountUuid }) } -export async function disable2fa( +export async function disable2fa ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1783,7 +1783,7 @@ export async function disable2fa( ctx.info('2FA disabled', { accountUuid }) } -export async function verify2fa( +export async function verify2fa ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1828,7 +1828,7 @@ export async function verify2fa( /* ==========READ OPERATIONS========== */ /* =================================== */ -export async function getRegionInfo( +export async function getRegionInfo ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1837,7 +1837,7 @@ export async function getRegionInfo( return getRegions() } -export async function getUserWorkspaces( +export async function getUserWorkspaces ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1853,7 +1853,7 @@ export async function getUserWorkspaces( /** * @public */ -export async function getWorkspacesInfo( +export async function getWorkspacesInfo ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1882,7 +1882,7 @@ export async function getWorkspacesInfo( /** * @public */ -export async function updateLastVisit( +export async function updateLastVisit ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1905,7 +1905,7 @@ export async function updateLastVisit( await db.workspaceStatus.update({ workspaceUuid: { $in: ids } }, { lastVisit: Date.now() }) } -export async function getWorkspaceInfo( +export async function getWorkspaceInfo ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -1954,7 +1954,7 @@ export async function getWorkspaceInfo( /** * Validates the token and returns the decoded account information. */ -export async function getLoginInfoByToken( +export async function getLoginInfoByToken ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2153,7 +2153,7 @@ export async function getLoginInfoByToken( /** * Validates the token and returns the decoded account information. */ -export async function getLoginWithWorkspaceInfo( +export async function getLoginWithWorkspaceInfo ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2224,23 +2224,23 @@ export async function getLoginWithWorkspaceInfo( isSystem || isDocGuest ? [] : userWorkspaces.map((it, idx) => [ - it.uuid, - { - url: it.url, - dataId: it.dataId, - mode: it.status.mode, - endpoint: getWorkspaceEndpoint(info, it.uuid, it.region), - role: roles.get(it.uuid) ?? null, - version: { - versionMajor: it.status.versionMajor, - versionMinor: it.status.versionMinor, - versionPatch: it.status.versionPatch - }, - progress: it.status.processingProgress, - branding: it.branding, - passwordAgingRule: it.passwordAgingRule - } - ]) + it.uuid, + { + url: it.url, + dataId: it.dataId, + mode: it.status.mode, + endpoint: getWorkspaceEndpoint(info, it.uuid, it.region), + role: roles.get(it.uuid) ?? null, + version: { + versionMajor: it.status.versionMajor, + versionMinor: it.status.versionMinor, + versionPatch: it.status.versionPatch + }, + progress: it.status.processingProgress, + branding: it.branding, + passwordAgingRule: it.passwordAgingRule + } + ]) ), socialIds } @@ -2255,12 +2255,12 @@ export async function getLoginWithWorkspaceInfo( return loginInfo } -export async function getSocialIds( +export async function getSocialIds ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { confirmed: boolean; includeDeleted: boolean } + params: { confirmed: boolean, includeDeleted: boolean } ): Promise { const { confirmed = true, includeDeleted = false } = params const { account: accountUuid, sub } = decodeTokenVerbose(ctx, token) @@ -2276,7 +2276,7 @@ export async function getSocialIds( return includeDeleted ? socialIds : socialIds.filter((si) => si.isDeleted !== true) } -export async function isReadOnlyGuest( +export async function isReadOnlyGuest ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2286,7 +2286,7 @@ export async function isReadOnlyGuest( return account === readOnlyGuestAccountUuid } -export async function getPerson( +export async function getPerson ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2303,12 +2303,12 @@ export async function getPerson( return person } -export async function findPersonBySocialId( +export async function findPersonBySocialId ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { socialId: PersonId; requireAccount?: boolean } + params: { socialId: PersonId, requireAccount?: boolean } ): Promise { const { socialId, requireAccount } = params @@ -2335,12 +2335,12 @@ export async function findPersonBySocialId( return socialIdObj.personUuid } -export async function findSocialIdBySocialKey( +export async function findSocialIdBySocialKey ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { socialKey: string; requireAccount?: boolean } + params: { socialKey: string, requireAccount?: boolean } ): Promise { const { socialKey, requireAccount } = params decodeTokenVerbose(ctx, token) @@ -2366,7 +2366,7 @@ export async function findSocialIdBySocialKey( return socialIdObj._id } -export async function getWorkspaceMembers( +export async function getWorkspaceMembers ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2387,7 +2387,7 @@ export async function getWorkspaceMembers( return await db.getWorkspaceMembers(workspace) } -export async function getAccountInfo( +export async function getAccountInfo ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2407,7 +2407,7 @@ export async function getAccountInfo( return { timezone: account?.timezone, locale: account?.locale, tfaEnabled: account?.tfaSecret != null } } -export async function ensurePerson( +export async function ensurePerson ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2418,7 +2418,7 @@ export async function ensurePerson( firstName: string lastName: string } -): Promise<{ uuid: PersonUuid; socialId: PersonId }> { +): Promise<{ uuid: PersonUuid, socialId: PersonId }> { const { account, workspace, extra } = decodeTokenVerbose(ctx, token) const allowedService = verifyAllowedServices( ['tool', 'workspace', 'schedule', 'mail', 'github', 'hulygram'], @@ -2451,7 +2451,7 @@ export async function ensurePerson( return { uuid: personUuid, socialId: newSocialId } } -async function getMailboxOptions( +async function getMailboxOptions ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2467,7 +2467,7 @@ async function getMailboxOptions( } } -async function createMailbox( +async function createMailbox ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2476,7 +2476,7 @@ async function createMailbox( name: string domain: string } -): Promise<{ mailbox: string; socialId: PersonId }> { +): Promise<{ mailbox: string, socialId: PersonId }> { const { name, domain } = params if (name == null || name === '' || domain == null || domain === '') { @@ -2514,7 +2514,7 @@ async function createMailbox( return { mailbox, socialId } } -async function getMailboxes( +async function getMailboxes ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2524,7 +2524,7 @@ async function getMailboxes( return await db.mailbox.find({ accountUuid: account }) } -async function getMailboxSecret( +async function getMailboxSecret ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2538,7 +2538,7 @@ async function getMailboxSecret( return await db.mailboxSecret.findOne({ mailbox: params.mailbox }) } -async function deleteMailbox( +async function deleteMailbox ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2579,7 +2579,7 @@ async function deleteMailbox( * @throws BadRequest if validation fails * @throws Forbidden if user lacks workspace access */ -async function createApiToken( +async function createApiToken ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2590,7 +2590,7 @@ async function createApiToken( expiryDays: number scopes?: string[] } -): Promise<{ id: string; token: string; expiresOn: number }> { +): Promise<{ id: string, token: string, expiresOn: number }> { const { name, workspaceUuid, expiryDays, scopes } = params if ( @@ -2671,7 +2671,7 @@ async function createApiToken( * Lists all API tokens for the authenticated user across all workspaces. * Includes workspace names resolved from workspace UUIDs. */ -async function listApiTokens( +async function listApiTokens ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2686,7 +2686,7 @@ async function listApiTokens( expiresOn: number revoked: boolean }> -> { + > { const { account } = decodeTokenVerbose(ctx, token) const tokens = await db.apiToken.find({ accountUuid: account }) @@ -2711,7 +2711,7 @@ async function listApiTokens( * The JWT itself remains valid until expiry — full revocation requires * a denylist check at the transactor level (future enhancement). */ -async function revokeApiToken( +async function revokeApiToken ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2738,7 +2738,7 @@ async function revokeApiToken( * Checks if a specific API token has been revoked. * Used by the transactor to enforce revocation at the request level. */ -async function checkApiTokenRevoked( +async function checkApiTokenRevoked ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2757,7 +2757,7 @@ async function checkApiTokenRevoked( * Lists all API tokens for a workspace. Requires OWNER role. * Returns tokens from all members, with account UUIDs for attribution. */ -async function listWorkspaceApiTokens( +async function listWorkspaceApiTokens ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2773,7 +2773,7 @@ async function listWorkspaceApiTokens( expiresOn: number revoked: boolean }> -> { + > { const { account } = decodeTokenVerbose(ctx, token) const { workspaceUuid } = params @@ -2798,12 +2798,12 @@ async function listWorkspaceApiTokens( /** * Revoke any token in the workspace. Requires OWNER role. */ -async function revokeWorkspaceApiToken( +async function revokeWorkspaceApiToken ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { tokenId: string; workspaceUuid: WorkspaceUuid } + params: { tokenId: string, workspaceUuid: WorkspaceUuid } ): Promise { const { account } = decodeTokenVerbose(ctx, token) const { tokenId, workspaceUuid } = params @@ -2822,7 +2822,7 @@ async function revokeWorkspaceApiToken( ctx.info('Workspace API token revoked by owner', { tokenId, account, workspaceUuid }) } -async function exchangeGuestToken( +async function exchangeGuestToken ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2852,7 +2852,7 @@ async function exchangeGuestToken( return token } -async function addEmailSocialId( +async function addEmailSocialId ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2903,7 +2903,7 @@ async function addEmailSocialId( return await sendOtp(ctx, db, branding, targetSocialId) } -async function addHulyAssistantSocialId( +async function addHulyAssistantSocialId ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2914,7 +2914,7 @@ async function addHulyAssistantSocialId( return await addSocialIdBase(db, account, SocialIdType.HULY_ASSISTANT, account, true) } -export async function refreshHulyAssistantToken( +export async function refreshHulyAssistantToken ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -2954,12 +2954,12 @@ export async function refreshHulyAssistantToken( } } -export async function releaseSocialId( +export async function releaseSocialId ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, token: string, - params: { personUuid?: PersonUuid; type: SocialIdType; value: string; deleteIntegrations?: boolean } + params: { personUuid?: PersonUuid, type: SocialIdType, value: string, deleteIntegrations?: boolean } ): Promise { const { account, extra } = decodeTokenVerbose(ctx, token) let { personUuid } = params @@ -3001,7 +3001,7 @@ export async function releaseSocialId( return await doReleaseSocialId(db, personUuid, type, value, extra?.service ?? account, deleteIntegrations) } -export async function deleteAccount( +export async function deleteAccount ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3030,7 +3030,7 @@ export async function deleteAccount( }) } -export async function canMergeSpecifiedPersons( +export async function canMergeSpecifiedPersons ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3071,7 +3071,7 @@ export async function canMergeSpecifiedPersons( return verifiedSecondaryIds.length === 0 } -export async function mergeSpecifiedPersons( +export async function mergeSpecifiedPersons ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3091,7 +3091,7 @@ export async function mergeSpecifiedPersons( await doMergePersons(db, primaryPerson, secondaryPerson) } -async function setMyProfile( +async function setMyProfile ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3141,7 +3141,7 @@ async function setMyProfile( } } -async function getUserProfile( +async function getUserProfile ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3208,7 +3208,7 @@ async function getUserProfile( * By default returns only active subscriptions. Set activeOnly=false to include historical subscriptions. * @public */ -export async function getSubscriptions( +export async function getSubscriptions ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3267,7 +3267,7 @@ export async function getSubscriptions( * - Regular users: Can only query subscriptions from their workspace (from token) * @public */ -export async function getSubscriptionById( +export async function getSubscriptionById ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3320,7 +3320,7 @@ export async function getSubscriptionById( return subscription } -export async function batchAssignWorkspacePermission( +export async function batchAssignWorkspacePermission ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3345,7 +3345,7 @@ export async function batchAssignWorkspacePermission( await db.batchAssignWorkspacePermission(workspace, accountIds, permission) } -export async function batchRevokeWorkspacePermission( +export async function batchRevokeWorkspacePermission ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3370,7 +3370,7 @@ export async function batchRevokeWorkspacePermission( await db.batchRevokeWorkspacePermission(workspace, accountIds, permission) } -export async function hasWorkspacePermission( +export async function hasWorkspacePermission ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3390,7 +3390,7 @@ export async function hasWorkspacePermission( return await db.hasWorkspacePermission(accountId, workspace, permission) } -export async function getWorkspacePermissions( +export async function getWorkspacePermissions ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3419,7 +3419,7 @@ export async function getWorkspacePermissions( return await db.getWorkspacePermissions(accountId, permission) } -export async function getWorkspaceUsersWithPermission( +export async function getWorkspaceUsersWithPermission ( ctx: MeasureContext, db: AccountDB, branding: Branding | null, @@ -3530,7 +3530,7 @@ export type AccountMethods = /** * @public */ -export function getMethods(hasSignUp: boolean = true): Partial> { +export function getMethods (hasSignUp: boolean = true): Partial> { return { /* OPERATIONS */ login: wrap(login), From dfa360b240e46449aec47ffb3a38ade154d22b4a Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Thu, 23 Apr 2026 19:36:03 -0400 Subject: [PATCH 9/9] fix: restore (s as any) cast in server_http.ts removed by ESLint autofix Signed-off-by: Don Kendall --- pods/server/src/server_http.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pods/server/src/server_http.ts b/pods/server/src/server_http.ts index 6c238795891..13b35d5ccc5 100644 --- a/pods/server/src/server_http.ts +++ b/pods/server/src/server_http.ts @@ -515,7 +515,8 @@ export function startHttpServer ( }, 1000) } if ('upgrade' in s) { - void cs.send(ctx, { id: -1, result: { state: 'upgrading', stats: s.upgradeInfo } }, false, false).then(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + void cs.send(ctx, { id: -1, result: { state: 'upgrading', stats: (s as any).upgradeInfo } }, false, false).then(() => { cs.close() }) }