diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 0a15fa45ed4..fcfeb4517fc 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, @@ -260,6 +262,9 @@ 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 setCookie: () => Promise deleteCookie: () => Promise @@ -1233,6 +1238,33 @@ 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 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 c7c68a45a51..a22e7559f88 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -114,6 +114,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/foundations/core/packages/token/src/__tests__/token.test.ts b/foundations/core/packages/token/src/__tests__/token.test.ts index fc54e62991f..31080ceb178 100644 --- a/foundations/core/packages/token/src/__tests__/token.test.ts +++ b/foundations/core/packages/token/src/__tests__/token.test.ts @@ -14,8 +14,8 @@ // import { setMetadata } from '@hcengineering/platform' -import type { PersonUuid, WorkspaceUuid } from '@hcengineering/core' -import { decodeToken, generateToken } from '../token' +import type { AccountUuid, PersonUuid, WorkspaceUuid } from '@hcengineering/core' +import { decodeToken, generateToken, isTokenExpired, setApiTokenRevocationChecker, verifyToken } from '../token' import plugin from '../plugin' export function decodeTokenPayload (token: string): any { @@ -114,3 +114,96 @@ describe('generateToken', () => { }) }) }) + +const ACCOUNT = '123e4567-e89b-12d3-a456-426614174000' as AccountUuid +const WORKSPACE = '123e4567-e89b-12d3-a456-426614174001' as WorkspaceUuid + +describe('isTokenExpired', () => { + it('is false when exp is absent', () => { + expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE })).toBe(false) + }) + + it('is false when exp is in the future', () => { + const exp = Math.floor(Date.now() / 1000) + 3600 + expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE, exp })).toBe(false) + }) + + it('is true when exp is in the past', () => { + const exp = Math.floor(Date.now() / 1000) - 1 + expect(isTokenExpired({ account: ACCOUNT, workspace: WORKSPACE, exp })).toBe(true) + }) +}) + +describe('verifyToken', () => { + beforeEach(() => { + setMetadata(plugin.metadata.Secret, undefined) + setMetadata(plugin.metadata.Service, undefined) + setApiTokenRevocationChecker(undefined) + }) + + afterAll(() => { + setApiTokenRevocationChecker(undefined) + }) + + it('returns the decoded token for a valid, non-expiring token', async () => { + const token = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret') + const decoded = await verifyToken(token, 'secret') + expect(decoded.account).toBe(ACCOUNT) + expect(decoded.workspace).toBe(WORKSPACE) + }) + + it('throws for an expired token', async () => { + const exp = Math.floor(Date.now() / 1000) - 1 + const token = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret', { exp }) + await expect(verifyToken(token, 'secret')).rejects.toThrow('Token expired') + }) + + it('skips revocation when no checker is registered', async () => { + const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-1' }, 'secret') + const decoded = await verifyToken(token, 'secret') + expect(decoded.extra?.apiTokenId).toBe('tok-1') + }) + + it('throws when the registered checker reports the API token revoked', async () => { + setApiTokenRevocationChecker(async () => true) + const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-revoked' }, 'secret') + await expect(verifyToken(token, 'secret')).rejects.toThrow('Token revoked') + }) + + it('refuses the token when revocation cannot be verified', async () => { + // The account is the only authority on revocation. Failing open here would let + // a revoked token survive for as long as an attacker can keep the account busy. + setApiTokenRevocationChecker(async () => { + throw new Error('account unreachable') + }) + const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-unreachable' }, 'secret') + await expect(verifyToken(token, 'secret')).rejects.toThrow('Token revocation could not be verified') + }) + + it('does not re-ask while a verdict is still fresh', async () => { + let calls = 0 + setApiTokenRevocationChecker(async () => { + calls++ + return false + }) + const token = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-cached' }, 'secret') + await verifyToken(token, 'secret') + await verifyToken(token, 'secret') + expect(calls).toBe(1) + }) + + it('only invokes the checker for revokable (API) tokens', async () => { + let calls = 0 + setApiTokenRevocationChecker(async () => { + calls++ + return false + }) + const plain = generateToken(ACCOUNT, WORKSPACE, undefined, 'secret') + await verifyToken(plain, 'secret') + expect(calls).toBe(0) + + const api = generateToken(ACCOUNT, WORKSPACE, { apiTokenId: 'tok-2' }, 'secret') + await verifyToken(api, 'secret') + expect(calls).toBe(1) + }) +}) diff --git a/foundations/core/packages/token/src/token.ts b/foundations/core/packages/token/src/token.ts index 7a6ae0eabf5..550576e2d70 100644 --- a/foundations/core/packages/token/src/token.ts +++ b/foundations/core/packages/token/src/token.ts @@ -138,3 +138,94 @@ export function decodeTokenVerbose (ctx: MeasureContext, token: string): Token { throw new TokenError(err.message) } } + +/** + * Checks whether a token has passed its `exp` (seconds since epoch) deadline. + * `decodeToken` only verifies the signature — expiry must be checked separately. + * @public + */ +export function isTokenExpired (token: Token, now: number = Date.now()): boolean { + return token.exp !== undefined && token.exp * 1000 <= now +} + +/** + * Resolves whether a revokable API token (identified by `extra.apiTokenId`) + * has been revoked. Registered by services that can reach the account + * (see {@link setApiTokenRevocationChecker}); other services skip the check. + * @public + */ +export type ApiTokenRevocationChecker = (apiTokenId: string, token: Token, raw: string) => Promise + +let apiTokenRevocationChecker: ApiTokenRevocationChecker | undefined + +const REVOCATION_CACHE_TTL_MS = 60_000 +const REVOCATION_CACHE_LIMIT = 4096 +const revocationCache = new Map() + +function cacheRevocation (apiTokenId: string, revoked: boolean, now: number): void { + // Bounded so a stream of distinct tokens cannot grow this without limit. + if (revocationCache.size >= REVOCATION_CACHE_LIMIT && !revocationCache.has(apiTokenId)) { + for (const [key, value] of revocationCache) { + if (now - value.checkedAt > REVOCATION_CACHE_TTL_MS) { + revocationCache.delete(key) + } + } + if (revocationCache.size >= REVOCATION_CACHE_LIMIT) { + revocationCache.delete(revocationCache.keys().next().value as string) + } + } + revocationCache.set(apiTokenId, { revoked, checkedAt: now }) +} + +/** + * Registers the revocation resolver used by {@link verifyToken}. Services with + * an account client install this once at startup; this is the "method to verify" + * metadata the token plugin needs to enforce revocation without depending on the + * account client directly. + * @public + */ +export function setApiTokenRevocationChecker (checker: ApiTokenRevocationChecker | undefined): void { + apiTokenRevocationChecker = checker + revocationCache.clear() +} + +async function isApiTokenRevoked (apiTokenId: string, token: Token, raw: string, now: number): Promise { + const cached = revocationCache.get(apiTokenId) + if (cached !== undefined && now - cached.checkedAt <= REVOCATION_CACHE_TTL_MS) { + return cached.revoked + } + + try { + const revoked = await (apiTokenRevocationChecker as ApiTokenRevocationChecker)(apiTokenId, token, raw) + cacheRevocation(apiTokenId, revoked, now) + return revoked + } catch { + // The account is the only authority on revocation. If it cannot be reached we + // do not know whether this token still stands, so refuse it rather than let a + // revoked token survive by making the account unreachable. A verdict from + // within the TTL is still trusted, which keeps brief outages from cutting off + // healthy tokens mid-flight. + throw new TokenError('Token revocation could not be verified') + } +} + +/** + * Decodes and fully validates a token: signature (via {@link decodeToken}), + * expiry, and — for revokable API tokens — revocation. Reuse this instead of + * `decodeToken` anywhere expired or revoked tokens must be rejected (transactor + * REST API, blob access, etc.) so the policy lives in one place. + * @public + */ +export async function verifyToken (token: string, secret?: string): Promise { + const decoded = decodeToken(token, true, secret) + if (isTokenExpired(decoded)) { + throw new TokenError('Token expired') + } + const apiTokenId = decoded.extra?.apiTokenId + if (apiTokenId !== undefined && apiTokenRevocationChecker !== undefined) { + if (await isApiTokenRevoked(apiTokenId, decoded, token, Date.now())) { + throw new TokenError('Token revoked') + } + } + return decoded +} diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 52c15d8ec79..9778abb96e0 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -440,6 +440,23 @@ export function createModel (builder: Builder): void { setting.ids.OfficeSettings ) + // Tokens belong to the account, not to a workspace: they are listed across every + // workspace the user is in, and creating one only needs the User role the account + // service checks. So this sits with the other per-account settings. + builder.createDoc( + setting.class.SettingsCategory, + core.space.Model, + { + name: 'apiTokens', + label: setting.string.ApiTokens, + icon: setting.icon.ApiToken, + component: setting.component.ApiTokens, + group: 'settings-account', + order: 1500, + role: AccountRole.User + }, + 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 bdcef592cd5..acbd43fc2ce 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -215,9 +215,6 @@ "IntegerOnly": "Pouze celá čísla", "AccessControl": "Řízení přístupu", "DangerZone": "Nebezpečná zóna", - "ApiAccess": "Přístup k API", - "ApiToken": "API token", - "GenerateApiToken": "Vygenerovat API token", "IdentifierExists": "Identifikátor již existuje", "PasswordAgingRule": "Pravidlo stárnutí hesla", "PasswordAgingRuleDescription": "Počet dní, po kterých budou uživatelé muset změnit své heslo.", @@ -247,6 +244,43 @@ "ShowQRCode": "Zobrazit QR kód", "EnterVerificationCode": "Zadejte ověřovací kód", "OverrideAttribute": "Přepsat atribut", - "Required": "Požadované" + "Required": "Požadované", + "ApiBaseUrl": "Základní URL", + "ApiEndpointAccount": "Získat informace o účtu", + "ApiEndpointFindAll": "Dotaz na dokumenty podle třídy", + "ApiEndpointFindAllPost": "Dotaz s filtry (tělo JSON)", + "ApiEndpointLoadModel": "Načíst datový model", + "ApiEndpointPing": "Kontrola stavu", + "ApiEndpointTx": "Vytvoření nebo aktualizace dokumentů", + "ApiTokenCopyWarning": "Zkopírujte si tento token nyní. Později jej už neuvidíte.", + "ApiTokenCreated": "Token vytvořen", + "ApiTokenExpiry": "Platnost", + "ApiTokenName": "Název tokenu", + "ApiTokenNoTokens": "Zatím žádné API tokeny", + "ApiTokenRevoke": "Odvolat token", + "ApiTokenRevokeConfirm": "Opravdu chcete tento token odvolat? Už jej nebude možné použít pro přístup k API.", + "ApiTokenWorkspace": "Pracovní prostor", + "ApiTokens": "API tokeny", + "ApiUsageDescription": "Použijte svůj API token s vestavěným REST API k dotazování a úpravě dat pracovního prostoru. Token předejte jako Bearer token v hlavičce Authorization.", + "ApiUsageTitle": "Použití REST API", + "ApiWorkspaceId": "ID vašeho pracovního prostoru (UUID) je součástí tokenu. Předejte jej jako :workspaceId v URL.", + "CreateApiToken": "Vytvořit token", + "Created": "Vytvořeno", + "Expires": "Vyprší", + "Login": "Login", + "Primary": "Primary", + "TokenStatus": "Stav", + "ApiTokenStatusActive": "Aktivní", + "ApiTokenStatusExpiring": "Vyprší", + "ApiTokenStatusRevoked": "Odvolán", + "ApiTokenStatusExpired": "Vypršel", + "ApiTokenExpiry7Days": "7 dní", + "ApiTokenExpiry30Days": "30 dní", + "ApiTokenExpiry90Days": "90 dní", + "ApiTokenExpiry180Days": "180 dní", + "ApiTokenExpiry365Days": "365 dní", + "ApiTokenLoadError": "Nepodařilo se načíst API tokeny", + "ApiTokenCreateError": "Nepodařilo se vytvořit token. Zkuste to prosím znovu.", + "ApiTokenRevokeError": "Odvolání tokenu se nezdařilo. Zkuste to prosím znovu." } } diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index 7408e34277e..44ab70feeca 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -217,9 +217,6 @@ "IntegerOnly": "Nur ganze Zahlen", "AccessControl": "Zugriffskontrolle", "DangerZone": "Gefahrenzone", - "ApiAccess": "API-Zugriff", - "ApiToken": "API-Token", - "GenerateApiToken": "API-Token generieren", "IdentifierExists": "Bezeichner existiert bereits", "PasswordAgingRule": "Passwort-Alterungsregel", "PasswordAgingRuleDescription": "Anzahl der Tage, nach denen Benutzer ihr Passwort ändern müssen.", @@ -249,6 +246,42 @@ "ShowQRCode": "QR-Code anzeigen", "EnterVerificationCode": "Verifizierungscode eingeben", "OverrideAttribute": "Überschreibattribut", - "Required": "Pflichtfeld" + "Required": "Pflichtfeld", + "ApiBaseUrl": "Basis-URL", + "ApiEndpointAccount": "Kontoinformationen abrufen", + "ApiEndpointFindAll": "Dokumente nach Klasse abfragen", + "ApiEndpointFindAllPost": "Abfrage mit Filtern (JSON-Body)", + "ApiEndpointLoadModel": "Datenmodell laden", + "ApiEndpointPing": "Funktionsprüfung", + "ApiEndpointTx": "Dokumente erstellen oder aktualisieren", + "ApiTokenCopyWarning": "Kopiere diesen Token jetzt. Du kannst ihn später nicht mehr einsehen.", + "ApiTokenCreated": "Token erstellt", + "ApiTokenExpiry": "Ablauf", + "ApiTokenName": "Token-Name", + "ApiTokenNoTokens": "Noch keine API-Token", + "ApiTokenRevoke": "Token widerrufen", + "ApiTokenRevokeConfirm": "Möchtest du diesen Token wirklich widerrufen? Er kann dann nicht mehr für den API-Zugriff verwendet werden.", + "ApiTokenWorkspace": "Arbeitsbereich", + "ApiTokens": "API-Token", + "ApiUsageDescription": "Verwende deinen API-Token mit der integrierten REST-API, um Arbeitsbereichsdaten abzufragen und zu ändern. Übergib den Token als Bearer-Token im Authorization-Header.", + "ApiUsageTitle": "Verwendung der REST-API", + "ApiWorkspaceId": "Die ID deines Arbeitsbereichs (UUID) ist im Token enthalten. Übergib sie als :workspaceId in der 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": "Token erstellen", + "Created": "Erstellt", + "Expires": "Läuft ab", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Aktiv", + "ApiTokenStatusExpiring": "Läuft ab", + "ApiTokenStatusRevoked": "Widerrufen", + "ApiTokenStatusExpired": "Abgelaufen", + "ApiTokenExpiry7Days": "7 Tage", + "ApiTokenExpiry30Days": "30 Tage", + "ApiTokenExpiry90Days": "90 Tage", + "ApiTokenExpiry180Days": "180 Tage", + "ApiTokenExpiry365Days": "365 Tage", + "ApiTokenLoadError": "API-Token konnten nicht geladen werden", + "ApiTokenCreateError": "Token konnte nicht erstellt werden. Bitte versuche es erneut.", + "ApiTokenRevokeError": "Token konnte nicht widerrufen werden. Bitte versuchen Sie es erneut." } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 838f5de3659..2693e105cd6 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -215,9 +215,6 @@ "IntegerOnly": "Integer numbers only", "AccessControl": "Access control", "DangerZone": "Danger zone", - "ApiAccess": "API access", - "ApiToken": "API token", - "GenerateApiToken": "Generate API token", "IdentifierExists": "Identifier already exists", "Reset": "Reset", "Restricted": "Restricted", @@ -249,6 +246,41 @@ "ShowQRCode": "Show QR code", "EnterVerificationCode": "Enter verification code", "OverrideAttribute": "Override attribute", - "Required": "Required" + "Required": "Required", + "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.", + "ApiTokenRevokeError": "Failed to revoke token. Please try again." } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index 81bccb24712..0e1e39559ae 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -208,9 +208,6 @@ "IntegerOnly": "Solo números enteros", "AccessControl": "Control de acceso", "DangerZone": "Zona de peligro", - "ApiAccess": "Acceso API", - "ApiToken": "Token API", - "GenerateApiToken": "Generar token API", "IdentifierExists": "El identificador ya existe", "PasswordAgingRule": "Regla de envejecimiento de contraseñas", "PasswordAgingRuleDescription": "Número de días después de los cuales se requerirá a los usuarios que cambien su contraseña.", @@ -240,6 +237,50 @@ "ShowQRCode": "Mostrar código QR", "EnterVerificationCode": "Introducir código de verificación", "OverrideAttribute": "Sobreescribir atributo", - "Required": "Requerido" + "Required": "Requerido", + "ApiBaseUrl": "URL base", + "ApiEndpointAccount": "Obtener información de la cuenta", + "ApiEndpointFindAll": "Consultar documentos por clase", + "ApiEndpointFindAllPost": "Consulta con filtros (cuerpo JSON)", + "ApiEndpointLoadModel": "Cargar el modelo de datos", + "ApiEndpointPing": "Comprobación de estado", + "ApiEndpointTx": "Crear o actualizar documentos", + "ApiTokenCopyWarning": "Copia este token ahora. No podrás volver a verlo.", + "ApiTokenCreated": "Token creado", + "ApiTokenExpiry": "Expiración", + "ApiTokenName": "Nombre del token", + "ApiTokenNoTokens": "Aún no hay tokens de API", + "ApiTokenRevoke": "Revocar token", + "ApiTokenRevokeConfirm": "¿Seguro que quieres revocar este token? Ya no se podrá usar para el acceso a la API.", + "ApiTokenWorkspace": "Espacio de trabajo", + "ApiTokens": "Tokens de API", + "ApiUsageDescription": "Usa tu token de API con la API REST integrada para consultar y modificar los datos del espacio de trabajo. Pasa el token como token Bearer en el encabezado Authorization.", + "ApiUsageTitle": "Uso de la API REST", + "ApiWorkspaceId": "El ID de tu espacio de trabajo (UUID) está incluido en el token. Pásalo como :workspaceId en la URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Crear token", + "Created": "Creado", + "Description": "Description", + "Expires": "Expira", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Estado", + "ApiTokenStatusActive": "Activo", + "ApiTokenStatusExpiring": "Por expirar", + "ApiTokenStatusRevoked": "Revocado", + "ApiTokenStatusExpired": "Expirado", + "ApiTokenExpiry7Days": "7 días", + "ApiTokenExpiry30Days": "30 días", + "ApiTokenExpiry90Days": "90 días", + "ApiTokenExpiry180Days": "180 días", + "ApiTokenExpiry365Days": "365 días", + "ApiTokenLoadError": "No se pudieron cargar los tokens de API", + "ApiTokenCreateError": "No se pudo crear el token. Inténtalo de nuevo.", + "ApiTokenRevokeError": "No se pudo revocar el token. Inténtalo de nuevo." } } diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 7ea973f0dd6..2d4e38c7a04 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -217,9 +217,6 @@ "IntegerOnly": "Nombres entiers uniquement", "AccessControl": "Contrôle d'accès", "DangerZone": "Zone dangereuse", - "ApiAccess": "Accès API", - "ApiToken": "Token API", - "GenerateApiToken": "Générer un token API", "IdentifierExists": "Identifiant déjà utilisé", "PasswordAgingRule": "Règle de vieillissement du mot de passe", "PasswordAgingRuleDescription": "Nombre de jours après lesquels les utilisateurs devront changer leur mot de passe.", @@ -249,6 +246,41 @@ "ShowQRCode": "Afficher le code QR", "EnterVerificationCode": "Entrer le code de vérification", "OverrideAttribute": "Surcharger l'attribut", - "Required": "Requis" + "Required": "Requis", + "ApiBaseUrl": "URL de base", + "ApiEndpointAccount": "Obtenir les informations du compte", + "ApiEndpointFindAll": "Interroger les documents par classe", + "ApiEndpointFindAllPost": "Requête avec filtres (corps JSON)", + "ApiEndpointLoadModel": "Charger le modèle de données", + "ApiEndpointPing": "Vérification de l’état", + "ApiEndpointTx": "Créer ou mettre à jour des documents", + "ApiTokenCopyWarning": "Copiez ce jeton maintenant. Vous ne pourrez plus le revoir.", + "ApiTokenCreated": "Jeton créé", + "ApiTokenExpiry": "Expiration", + "ApiTokenName": "Nom du jeton", + "ApiTokenNoTokens": "Aucun jeton API pour le moment", + "ApiTokenRevoke": "Révoquer le jeton", + "ApiTokenRevokeConfirm": "Voulez-vous vraiment révoquer ce jeton ? Il ne pourra plus être utilisé pour accéder à l’API.", + "ApiTokenWorkspace": "Espace de travail", + "ApiTokens": "Jetons API", + "ApiUsageDescription": "Utilisez votre jeton API avec l’API REST intégrée pour interroger et modifier les données de l’espace de travail. Transmettez le jeton en tant que jeton Bearer dans l’en-tête Authorization.", + "ApiUsageTitle": "Utilisation de l’API REST", + "ApiWorkspaceId": "L’identifiant de votre espace de travail (UUID) est inclus dans le jeton. Transmettez-le en tant que :workspaceId dans l’URL.", + "CreateApiToken": "Créer un jeton", + "Created": "Créé", + "Expires": "Expire", + "TokenStatus": "Statut", + "ApiTokenStatusActive": "Actif", + "ApiTokenStatusExpiring": "Expire bientôt", + "ApiTokenStatusRevoked": "Révoqué", + "ApiTokenStatusExpired": "Expiré", + "ApiTokenExpiry7Days": "7 jours", + "ApiTokenExpiry30Days": "30 jours", + "ApiTokenExpiry90Days": "90 jours", + "ApiTokenExpiry180Days": "180 jours", + "ApiTokenExpiry365Days": "365 jours", + "ApiTokenLoadError": "Échec du chargement des jetons API", + "ApiTokenCreateError": "Échec de la création du jeton. Veuillez réessayer.", + "ApiTokenRevokeError": "Échec de la révocation du jeton. Veuillez réessayer." } } diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index 4d556acaeb0..fd2b379f309 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -217,9 +217,6 @@ "IntegerOnly": "Solo numeri interi", "AccessControl": "Controllo accessi", "DangerZone": "Zona pericolosa", - "ApiAccess": "Accesso API", - "ApiToken": "Token API", - "GenerateApiToken": "Genera token API", "IdentifierExists": "Identificatore già esistente", "PasswordAgingRule": "Regola di invecchiamento della password", "PasswordAgingRuleDescription": "Numero di giorni dopo i quali agli utenti verrà richiesto di cambiare la password.", @@ -249,6 +246,41 @@ "ShowQRCode": "Mostra codice QR", "EnterVerificationCode": "Inserisci codice di verifica", "OverrideAttribute": "Sovrascrivi attributo", - "Required": "Richiesto" + "Required": "Richiesto", + "ApiBaseUrl": "URL di base", + "ApiEndpointAccount": "Ottieni le informazioni dell’account", + "ApiEndpointFindAll": "Interroga i documenti per classe", + "ApiEndpointFindAllPost": "Query con filtri (corpo JSON)", + "ApiEndpointLoadModel": "Carica il modello dati", + "ApiEndpointPing": "Controllo di stato", + "ApiEndpointTx": "Crea o aggiorna documenti", + "ApiTokenCopyWarning": "Copia subito questo token. Non potrai più visualizzarlo.", + "ApiTokenCreated": "Token creato", + "ApiTokenExpiry": "Scadenza", + "ApiTokenName": "Nome del token", + "ApiTokenNoTokens": "Nessun token API", + "ApiTokenRevoke": "Revoca token", + "ApiTokenRevokeConfirm": "Vuoi davvero revocare questo token? Non sarà più utilizzabile per l’accesso all’API.", + "ApiTokenWorkspace": "Area di lavoro", + "ApiTokens": "Token API", + "ApiUsageDescription": "Usa il tuo token API con l’API REST integrata per interrogare e modificare i dati dell’area di lavoro. Passa il token come token Bearer nell’intestazione Authorization.", + "ApiUsageTitle": "Utilizzo dell’API REST", + "ApiWorkspaceId": "L’ID della tua area di lavoro (UUID) è incluso nel token. Passalo come :workspaceId nell’URL.", + "CreateApiToken": "Crea token", + "Created": "Creato", + "Expires": "Scade", + "TokenStatus": "Stato", + "ApiTokenStatusActive": "Attivo", + "ApiTokenStatusExpiring": "In scadenza", + "ApiTokenStatusRevoked": "Revocato", + "ApiTokenStatusExpired": "Scaduto", + "ApiTokenExpiry7Days": "7 giorni", + "ApiTokenExpiry30Days": "30 giorni", + "ApiTokenExpiry90Days": "90 giorni", + "ApiTokenExpiry180Days": "180 giorni", + "ApiTokenExpiry365Days": "365 giorni", + "ApiTokenLoadError": "Impossibile caricare i token API", + "ApiTokenCreateError": "Impossibile creare il token. Riprova.", + "ApiTokenRevokeError": "Impossibile revocare il token. Riprova." } } diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index f767c713d2e..9d49ce7d1ab 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -217,9 +217,6 @@ "IntegerOnly": "整数のみ", "AccessControl": "アクセス制御", "DangerZone": "危険ゾーン", - "ApiAccess": "APIアクセス", - "ApiToken": "APIトークン", - "GenerateApiToken": "APIトークンを生成", "IdentifierExists": "識別子は既に存在します", "PasswordAgingRule": "パスワードエイジングルール", "PasswordAgingRuleDescription": "ユーザーがパスワードを変更する必要がある日数", @@ -249,6 +246,41 @@ "ShowQRCode": "QRコードを表示", "EnterVerificationCode": "確認コードを入力", "OverrideAttribute": "属性を上書き", - "Required": "必須" + "Required": "必須", + "ApiBaseUrl": "ベース URL", + "ApiEndpointAccount": "アカウント情報を取得", + "ApiEndpointFindAll": "クラスでドキュメントを照会", + "ApiEndpointFindAllPost": "フィルター付きで照会(JSON ボディ)", + "ApiEndpointLoadModel": "データモデルを読み込む", + "ApiEndpointPing": "ヘルスチェック", + "ApiEndpointTx": "ドキュメントの作成または更新", + "ApiTokenCopyWarning": "今すぐこのトークンをコピーしてください。再度表示することはできません。", + "ApiTokenCreated": "トークンを作成しました", + "ApiTokenExpiry": "有効期限", + "ApiTokenName": "トークン名", + "ApiTokenNoTokens": "API トークンはまだありません", + "ApiTokenRevoke": "トークンを取り消す", + "ApiTokenRevokeConfirm": "このトークンを取り消してもよろしいですか?取り消すと API アクセスに使用できなくなります。", + "ApiTokenWorkspace": "ワークスペース", + "ApiTokens": "API トークン", + "ApiUsageDescription": "組み込みの REST API で API トークンを使用して、ワークスペースのデータを照会・変更できます。トークンは Authorization ヘッダーに Bearer トークンとして渡してください。", + "ApiUsageTitle": "REST API の使用", + "ApiWorkspaceId": "ワークスペース ID(UUID)はトークンに含まれています。URL では :workspaceId として渡してください。", + "CreateApiToken": "トークンを作成", + "Created": "作成日", + "Expires": "有効期限", + "TokenStatus": "ステータス", + "ApiTokenStatusActive": "有効", + "ApiTokenStatusExpiring": "期限切れ間近", + "ApiTokenStatusRevoked": "取り消し済み", + "ApiTokenStatusExpired": "期限切れ", + "ApiTokenExpiry7Days": "7 日", + "ApiTokenExpiry30Days": "30 日", + "ApiTokenExpiry90Days": "90 日", + "ApiTokenExpiry180Days": "180 日", + "ApiTokenExpiry365Days": "365 日", + "ApiTokenLoadError": "API トークンの読み込みに失敗しました", + "ApiTokenCreateError": "トークンの作成に失敗しました。もう一度お試しください。", + "ApiTokenRevokeError": "トークンの取り消しに失敗しました。もう一度お試しください。" } } diff --git a/plugins/setting-assets/lang/ko.json b/plugins/setting-assets/lang/ko.json index c883efbce64..891fc5adbfa 100644 --- a/plugins/setting-assets/lang/ko.json +++ b/plugins/setting-assets/lang/ko.json @@ -1,254 +1,286 @@ { - "string": { - "Setting": "설정", - "Spaces": "스페이스", - "Integrations": "연동", - "Support": "고객 지원", - "Privacy": "개인정보 보호", - "Terms": "이용약관", - "AccountSettings": "계정 설정", - "Categories": "카테고리", - "Delete": "삭제", - "ChangePassword": "비밀번호 변경", - "Disconnect": "연결 해제", - "DisconnectAll": "모두 연결 해제", - "Saving": "저장 중...", - "Saved": "저장됨", - "Add": "추가", - "AddNew": "{type} 추가", - "Proceed": "계속", - "NewEmail": "새 이메일", - "SendConfirmation": "인증 코드 전송", - "CodeSent": "코드를 전송했습니다. 아래 입력란에 입력하세요.", - "SendAgain": "다시 보내기", - "SendAgainIn": "재전송 가능 시간", - "Value": "값", - "Signout": "로그아웃", - "Settings": "설정", - "SelectWorkspace": "워크스페이스 선택", - "InviteWorkspace": "워크스페이스에 초대", - "DeleteStatus": "상태 삭제", - "DeleteStatusConfirm": "이 상태를 삭제하시겠습니까?", - "Reconnect": "재연결", - "IntegrationDisabled": " 연동이 비활성화되었습니다", - "IntegrationDisabledSetting": "연동이 비활성화되었습니다", - "IntegrationDisabledDescr": "연동 비활성화됨", - "IntegrationWith": "다음과 연동: ", - "ClassSetting": "클래스 설정", - "ClassSettingHint": "종류, 유형 또는 품질에 따라 다른 것들과 공통의 속성을 가진 사물의 집합 또는 카테고리입니다.", - "ClassProperties": "클래스 속성", - "Classes": "클래스", - "Attributes": "속성", - "DeleteAttribute": "속성 삭제", - "DeleteAttributeConfirm": "이 속성을 삭제하시겠습니까?", - "DeleteAttributeExistConfirm": "이 속성을 삭제하시겠습니까? 데이터가 손실됩니다.", - "DeleteMixin": "믹스인 삭제", - "DeleteMixinConfirm": "이 Mixin을 삭제하시겠습니까?", - "DeleteMixinExistConfirm": "이 Mixin을 삭제하시겠습니까? 데이터를 사용할 수 없게 됩니다.", - "Attribute": "속성", - "Custom": "사용자 지정", - "Type": "유형", - "WithTime": "시간 포함", - "DateMode": "날짜 모드", - "CreatingAttribute": "속성 생성 중", - "EditAttribute": "속성 편집", - "CreateEnum": "열거형 생성", - "EditEnum": "열거형 편집", - "Enums": "열거형", - "EnumsSettingHint": "종류, 유형 또는 품질에 따라 다른 것들과 공통의 속성을 가진 사물의 집합 또는 카테고리입니다.", - "EnumTitle": "열거형 제목", - "EnumsCount": "{count, plural, =1 {옵션 1개} other {옵션 #개}}", - "ProjectTypesCount": "{count, plural, =0 {프로젝트 유형 없음} =1 {프로젝트 유형 1개} other {프로젝트 유형 #개}}", - "Options": "옵션", - "EnterOptionTitle": "옵션 제목 입력", - "NewEnumDialogClose": "이 대화 상자를 닫으시겠습니까?", - "NewEnumDialogCloseNote": "모든 변경 사항이 손실됩니다", - "NewValue": "새 값", - "Leave": "워크스페이스 나가기", - "LeaveDescr": "워크스페이스에서 나가시겠습니까? 이 작업은 되돌릴 수 없습니다.", - "Members": "멤버", - "WorkspaceSettings": "워크스페이스 설정", - "Select": "선택", - "AddOwner": "소유자 추가", - "ReadonlyGuest": "읽기 전용", - "Guest": "게스트", - "User": "사용자", - "Maintainer": "유지관리자", - "Owner": "소유자", - "OwnerFirstName": "소유자 이름", - "OwnerLastName": "소유자 성", - "Role": "역할", - "FailedToSave": "비밀번호 업데이트에 실패했습니다", - "ImportEnum": "열거형 값 가져오기", - "ImportEnumCopy": "클립보드에서 열거형 값 복사", - "CreateMixin": "믹스인 생성", - "OldNames": "이전 값", - "NewClassName": "새 클래스 이름을 입력하거나 이전 값에서 선택...", - "ShowAttribute": "속성 표시", - "HideAttribute": "속성 숨기기", - "Visibility": "표시 설정", - "Hidden": "숨김", - "Configure": "설정", - "InviteSettings": "초대 설정", - "RoleCapabilitySettings": "역할 권한", - "DefaultInviteRoleForJoin": "초대 링크로 참여 시 부여되는 기본 역할:", - "InviteLinkGeneratorRoles": "초대 링크를 생성할 수 있는 사용자 역할 선택:", - "DefaultValue": "기본값", - "SelectAValue": "값 선택", - "DateOnly": "날짜만", - "OnlyTime": "시간만", - "DateAndTime": "날짜와 시간", - "Configuration": "구성", - "ConfigurationEnabled": "활성화됨", - "ConfigurationDisabled": "비활성화됨", - "ConfigDisable": "비활성화", - "ConfigEnable": "활성화", - "ConfigBeta": "베타 버전", - "Properties": "속성", - "TaskTypes": "작업 유형", - "Automations": "자동화", - "Collections": "컬렉션", - "ClassColon": "클래스:", - "SpaceTypes": "스페이스 유형", - "NewSpaceType": "새 스페이스 유형", - "SpaceTypeTitle": "스페이스 유형 제목", - "General": "일반", - "Description": "설명", - "CountSpaces": "{count, plural, =0 {스페이스 없음} =1 {스페이스 1개} other {스페이스 #개}}", - "Roles": "역할", - "RoleName": "역할 이름", - "Permissions": "권한", - "Assignees": "담당자", - "DeleteRole": "역할 삭제", - "DeleteRoleConfirmation": "이 역할을 삭제하시겠습니까? 이 역할을 가진 모든 사용자가 권한을 잃게 됩니다.", - "DeleteWorkspace": "워크스페이스 삭제", - "DeleteWorkspaceConfirm": "이 워크스페이스를 삭제하시겠습니까? 본인과 다른 모든 멤버가 이 워크스페이스에 접근할 수 없게 되며, 워크스페이스의 모든 정보가 손실됩니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?", - "DeleteSpaceType": "스페이스 유형 삭제", - "DeleteSpaceTypeConfirm": "이 스페이스 유형을 삭제하시겠습니까?", - "WorkspaceName": "워크스페이스 이름", - "Workspace": "워크스페이스", - "OwnerOrMaintainerRequired": "워크스페이스 소유자 또는 유지관리자여야 합니다", - "LastOwnerLeaveTitle": "워크스페이스를 나갈 수 없습니다", - "LastOwnerLeaveMessage": "이 워크스페이스의 유일한 소유자입니다. 나가려면 먼저 다른 멤버에게 소유자 권한을 부여하세요. 더 이상 이 워크스페이스가 필요하지 않다면 삭제를 고려해 보세요.", - "Backup": "백업", - "BackupLast": "마지막 백업", - "BackupTotalSnapshots": "총 스냅샷", - "BackupTotalFiles": "파일", - "BackupSize": "백업 크기", - "BackupLinkInfo": "wget이나 curl 같은 도구로 재귀적으로 다운로드할 수 있는 백업 디렉터리의 URL입니다.", - "BackupBearerTokenInfo": "백업에 접근하려면 Bearer 토큰이 필요합니다.", - "BackupSnapshots": "백업 스냅샷", - "BackupFileDownload": "파일 다운로드", - "BackupFiles": "백업 파일", - "BackupNoBackup": "현재 사용 가능한 백업이 없습니다.", - "BackupDownloadAll": "전체 백업 다운로드", - "BackupPreparingDownload": "백업 준비 중…", - "BackupDownloadAllInfo": "모든 백업 파일을 컴퓨터에 보관할 수 있는 단일 .zip 아카이브로 다운로드합니다.", - "BackupCopyScript": "다운로드 스크립트 복사", - "BackupCopyToken": "토큰 복사", - "BackupScriptInfo": "curl로 모든 백업 파일을 다운로드하는 셸 스크립트입니다. 저장한 후 터미널에서 실행하세요. 백업 토큰을 입력하라는 메시지가 표시되므로 스크립트에 비밀 정보가 저장되지 않습니다.", - "BackupRestoreGuide": "백업 및 복원 가이드", - "BackupRestoreGuideInfo": "이 백업을 다운로드하여 다른 Huly 인스턴스로 복원하는 단계별 안내입니다.", - "NonBackupedBlobs": "백업되지 않은 Blob", - "Calendar": "캘린더", - "StartOfTheWeek": "주 시작일", - "SystemSetupString": "시스템 설정 ({day})", - "DefaultString": "기본값 ({day})", - "AddAttribute": "속성 추가", - "WorkspaceNamePattern": "이름은 40자 이하여야 하며, 비워둘 수 없고 특수 문자(<, >, /)를 포함할 수 없습니다", - "Mailboxes": "메일함", - "CreateMailbox": "메일함 생성", - "CreateMailboxPlaceholder": "my-cool-name", - "MailboxNoDomains": "이메일 도메인이 구성되지 않았습니다", - "MailboxLimitReached": "메일함 한도에 도달했습니다", - "MailboxErrorInvalidName": "메일함 이름이 유효하지 않습니다", - "MailboxErrorDomainNotFound": "도메인을 찾을 수 없습니다", - "MailboxErrorNameRulesViolated": "메일함 이름은 {minLen}~{maxLen}자여야 합니다", - "MailboxErrorMailboxExists": "이미 사용 중인 메일함 이름입니다", - "MailboxErrorMailboxCountLimit": "계정의 메일함 개수 한도에 도달했습니다", - "DeleteMailbox": "메일함 삭제", - "MailboxDeleteConfirmation": "이 메일함을 삭제하시겠습니까?", - "DisablePermissions": "역할 기반 접근 제어 비활성화", - "EnablePermissions": "역할 기반 접근 제어 활성화", - "DisablePermissionsConfirmation": "역할 기반 접근 제어를 비활성화하시겠습니까? 모든 역할과 권한이 비활성화됩니다.", - "EnablePermissionsConfirmation": "역할 기반 접근 제어를 활성화하시겠습니까? 모든 역할과 권한이 활성화됩니다.", - "BetaWarning": "베타로 표시된 모듈은 실험용이며 완전히 작동하지 않을 수 있습니다. 현재로서는 중요한 작업에 베타 기능을 사용하는 것을 권장하지 않습니다.", - "IntegrationFailed": "연동 생성에 실패했습니다", - "IntegrationError": "다시 시도하거나, 문제가 지속되면 지원팀에 문의하세요", - "EmailIsUsed": "이미 다른 계정에서 사용 중인 이메일 주소입니다", - "Customize": "사용자 정의", - "GuestAccess": "익명 게스트", - "GuestAccessDescription": "익명 사용자가 워크스페이스를 읽기 전용 모드로 방문할 수 있도록 허용", - "GuestSignUpDescription": "익명 사용자가 제한된 편집 권한의 게스트로 워크스페이스에 참여할 수 있도록 허용", - "GuestChannelsDescription": "참여 후 게스트가 메시지를 작성할 수 있는 채널", - "GuestChannelsArrayLabel": "채널 선택", - "GuestSelectSpaces": "스페이스 선택", - "GuestAutoJoinAvailableSpaces": "자동 참여 스페이스", - "GuestAutoJoinAvailableSpacesHint": "각 애플리케이션 카드에는 \"자동 참여 스페이스\" 행이 있습니다. 워크스페이스 게스트가 활성화될 때 추가될 위치를 선택하세요. 변경 사항은 즉시 적용됩니다.", - "GuestAnonymousVisibleSpaces": "익명 사용자에게 표시되는 스페이스", - "GuestAnonymousVisibleSpacesHint": "각 애플리케이션 카드에는 자체 행이 있습니다. 읽기 전용 익명 계정이 멤버로 추가되는 스페이스를 선택하면 계정이 없는 방문자도 해당 스페이스를 열 수 있습니다. 변경 사항은 즉시 적용됩니다.", - "ManageIdentities": "ID 관리", - "Release": "해제", - "ReleaseSocialId": "소셜 ID 해제", - "ReleaseSocialIdConfirm": "이 소셜 ID({socialId})를 해제하시겠습니까? 계정에서 제거되며 더 이상 로그인에 사용할 수 없습니다. 또한 관련된 모든 연동도 제거됩니다.", - "ReleasePrimarySocialId": "기본 소셜 ID 해제", - "ReleasePrimarySocialIdConfirm": "현재 기본 소셜 ID를 해제하려면 페이지를 새로고침해야 합니다. 계속하시겠습니까?", - "Login": "로그인", - "Primary": "기본", - "MyIntegrations": "내 연동", - "AllIntegrations": "전체", - "ConnectedIntegrations": "연동됨", - "AvailableIntegrations": "사용 가능", - "Connect": "연결", - "Integrate": "연동", - "FailedToLoadIntegrations": "연동을 불러오는 데 실패했습니다", - "FailedToDisconnect": "연동 연결 해제에 실패했습니다", - "ServiceIsUnavailable": "서비스를 사용할 수 없습니다", - "Integrated": "연동됨", - "Connected": "연결됨", - "Disconnected": "연결 해제됨", - "Available": "사용 가능", - "NotConnectedIntegration": "{account} 계정이 워크스페이스와 연동되어 있지 않습니다", - "IntegrationIsUnstable": "연동 서비스에 문제가 발생했습니다. 일부 기능이 제대로 작동하지 않을 수 있습니다.", - "MinValue": "최솟값", - "MaxValue": "최댓값", - "IntegerOnly": "정수만", - "AccessControl": "접근 제어", - "DangerZone": "위험 구역", - "ApiAccess": "API 접근", - "ApiToken": "API 토큰", - "GenerateApiToken": "API 토큰 생성", - "IdentifierExists": "이미 존재하는 식별자입니다", - "Reset": "재설정", - "Restricted": "제한됨", - "RestrictedAttributeWarning": "이 속성의 변경을 제한하시겠습니까? 이 속성에 대한 권한이 생성되며 작업은 되돌릴 수 없습니다.", - "PasswordAgingRule": "비밀번호 만료 규칙", - "PasswordAgingRuleDescription": "사용자가 비밀번호를 변경해야 하는 일수", - "OfficeSettings": "오피스 설정", - "OfficeDefaultSettings": "회의실 기본 설정", - "DefaultStartWithTranscription": "새 오피스 회의실에서 자동 기록 활성화", - "DefaultStartWithRecording": "새 오피스 회의실에서 녹화 활성화", - "GuestPermissionsSettings": "게스트", - "GuestPermissionsApplicationPermissions": "애플리케이션 권한", - "GuestPermissionsApplicationPermissionsHint": "게스트가 사용할 수 있는 애플리케이션을 선택한 다음, 아래에서 각 애플리케이션의 권한을 조정하세요.", - "GuestPermissionsTabGuest": "게스트", - "GuestPermissionsTabAnonymousGuest": "익명 게스트", - "GuestPermissionsAnonymousApplicationHint": "익명(읽기 전용) 게스트의 애플리케이션 접근 권한입니다. 표시되는 애플리케이션은 배포 구성에 따라 달라질 수 있습니다.", - "ImportDocumentPermission": "문서 가져오기", - "ImportDocumentDescription": "사용자에게 워크스페이스로 문서를 가져올 권한을 부여", - "SelectUsers": "사용자 선택", - "ShowInTitle": "제목에 표시", - "SpaceMembersOnly": "스페이스 멤버 전용", - "Security": "보안", - "TwoFactorAuth": "2단계 인증", - "TwoFactorAuthDescription": "2단계 인증은 계정에 추가적인 보안 계층을 더해 줍니다", - "EnableTwoFactorAuth": "2단계 인증 활성화", - "DisableTwoFactorAuth": "2단계 인증 비활성화", - "TwoFactorAuthEnabled": "2단계 인증이 활성화됨", - "TwoFactorAuthDisabled": "2단계 인증이 비활성화됨", - "ShowQRCode": "QR 코드 표시", - "EnterVerificationCode": "인증 코드 입력", - "OverrideAttribute": "속성 재정의", - "Required": "필수" - } + "string": { + "Setting": "설정", + "Spaces": "스페이스", + "Integrations": "연동", + "Support": "고객 지원", + "Privacy": "개인정보 보호", + "Terms": "이용약관", + "AccountSettings": "계정 설정", + "Categories": "카테고리", + "Delete": "삭제", + "ChangePassword": "비밀번호 변경", + "Disconnect": "연결 해제", + "DisconnectAll": "모두 연결 해제", + "Saving": "저장 중...", + "Saved": "저장됨", + "Add": "추가", + "AddNew": "{type} 추가", + "Proceed": "계속", + "NewEmail": "새 이메일", + "SendConfirmation": "인증 코드 전송", + "CodeSent": "코드를 전송했습니다. 아래 입력란에 입력하세요.", + "SendAgain": "다시 보내기", + "SendAgainIn": "재전송 가능 시간", + "Value": "값", + "Signout": "로그아웃", + "Settings": "설정", + "SelectWorkspace": "워크스페이스 선택", + "InviteWorkspace": "워크스페이스에 초대", + "DeleteStatus": "상태 삭제", + "DeleteStatusConfirm": "이 상태를 삭제하시겠습니까?", + "Reconnect": "재연결", + "IntegrationDisabled": " 연동이 비활성화되었습니다", + "IntegrationDisabledSetting": "연동이 비활성화되었습니다", + "IntegrationDisabledDescr": "연동 비활성화됨", + "IntegrationWith": "다음과 연동: ", + "ClassSetting": "클래스 설정", + "ClassSettingHint": "종류, 유형 또는 품질에 따라 다른 것들과 공통의 속성을 가진 사물의 집합 또는 카테고리입니다.", + "ClassProperties": "클래스 속성", + "Classes": "클래스", + "Attributes": "속성", + "DeleteAttribute": "속성 삭제", + "DeleteAttributeConfirm": "이 속성을 삭제하시겠습니까?", + "DeleteAttributeExistConfirm": "이 속성을 삭제하시겠습니까? 데이터가 손실됩니다.", + "DeleteMixin": "믹스인 삭제", + "DeleteMixinConfirm": "이 Mixin을 삭제하시겠습니까?", + "DeleteMixinExistConfirm": "이 Mixin을 삭제하시겠습니까? 데이터를 사용할 수 없게 됩니다.", + "Attribute": "속성", + "Custom": "사용자 지정", + "Type": "유형", + "WithTime": "시간 포함", + "DateMode": "날짜 모드", + "CreatingAttribute": "속성 생성 중", + "EditAttribute": "속성 편집", + "CreateEnum": "열거형 생성", + "EditEnum": "열거형 편집", + "Enums": "열거형", + "EnumsSettingHint": "종류, 유형 또는 품질에 따라 다른 것들과 공통의 속성을 가진 사물의 집합 또는 카테고리입니다.", + "EnumTitle": "열거형 제목", + "EnumsCount": "{count, plural, =1 {옵션 1개} other {옵션 #개}}", + "ProjectTypesCount": "{count, plural, =0 {프로젝트 유형 없음} =1 {프로젝트 유형 1개} other {프로젝트 유형 #개}}", + "Options": "옵션", + "EnterOptionTitle": "옵션 제목 입력", + "NewEnumDialogClose": "이 대화 상자를 닫으시겠습니까?", + "NewEnumDialogCloseNote": "모든 변경 사항이 손실됩니다", + "NewValue": "새 값", + "Leave": "워크스페이스 나가기", + "LeaveDescr": "워크스페이스에서 나가시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "Members": "멤버", + "WorkspaceSettings": "워크스페이스 설정", + "Select": "선택", + "AddOwner": "소유자 추가", + "ReadonlyGuest": "읽기 전용", + "Guest": "게스트", + "User": "사용자", + "Maintainer": "유지관리자", + "Owner": "소유자", + "OwnerFirstName": "소유자 이름", + "OwnerLastName": "소유자 성", + "Role": "역할", + "FailedToSave": "비밀번호 업데이트에 실패했습니다", + "ImportEnum": "열거형 값 가져오기", + "ImportEnumCopy": "클립보드에서 열거형 값 복사", + "CreateMixin": "믹스인 생성", + "OldNames": "이전 값", + "NewClassName": "새 클래스 이름을 입력하거나 이전 값에서 선택...", + "ShowAttribute": "속성 표시", + "HideAttribute": "속성 숨기기", + "Visibility": "표시 설정", + "Hidden": "숨김", + "Configure": "설정", + "InviteSettings": "초대 설정", + "RoleCapabilitySettings": "역할 권한", + "DefaultInviteRoleForJoin": "초대 링크로 참여 시 부여되는 기본 역할:", + "InviteLinkGeneratorRoles": "초대 링크를 생성할 수 있는 사용자 역할 선택:", + "DefaultValue": "기본값", + "SelectAValue": "값 선택", + "DateOnly": "날짜만", + "OnlyTime": "시간만", + "DateAndTime": "날짜와 시간", + "Configuration": "구성", + "ConfigurationEnabled": "활성화됨", + "ConfigurationDisabled": "비활성화됨", + "ConfigDisable": "비활성화", + "ConfigEnable": "활성화", + "ConfigBeta": "베타 버전", + "Properties": "속성", + "TaskTypes": "작업 유형", + "Automations": "자동화", + "Collections": "컬렉션", + "ClassColon": "클래스:", + "SpaceTypes": "스페이스 유형", + "NewSpaceType": "새 스페이스 유형", + "SpaceTypeTitle": "스페이스 유형 제목", + "General": "일반", + "Description": "설명", + "CountSpaces": "{count, plural, =0 {스페이스 없음} =1 {스페이스 1개} other {스페이스 #개}}", + "Roles": "역할", + "RoleName": "역할 이름", + "Permissions": "권한", + "Assignees": "담당자", + "DeleteRole": "역할 삭제", + "DeleteRoleConfirmation": "이 역할을 삭제하시겠습니까? 이 역할을 가진 모든 사용자가 권한을 잃게 됩니다.", + "DeleteWorkspace": "워크스페이스 삭제", + "DeleteWorkspaceConfirm": "이 워크스페이스를 삭제하시겠습니까? 본인과 다른 모든 멤버가 이 워크스페이스에 접근할 수 없게 되며, 워크스페이스의 모든 정보가 손실됩니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?", + "DeleteSpaceType": "스페이스 유형 삭제", + "DeleteSpaceTypeConfirm": "이 스페이스 유형을 삭제하시겠습니까?", + "WorkspaceName": "워크스페이스 이름", + "Workspace": "워크스페이스", + "OwnerOrMaintainerRequired": "워크스페이스 소유자 또는 유지관리자여야 합니다", + "LastOwnerLeaveTitle": "워크스페이스를 나갈 수 없습니다", + "LastOwnerLeaveMessage": "이 워크스페이스의 유일한 소유자입니다. 나가려면 먼저 다른 멤버에게 소유자 권한을 부여하세요. 더 이상 이 워크스페이스가 필요하지 않다면 삭제를 고려해 보세요.", + "Backup": "백업", + "BackupLast": "마지막 백업", + "BackupTotalSnapshots": "총 스냅샷", + "BackupTotalFiles": "파일", + "BackupSize": "백업 크기", + "BackupLinkInfo": "wget이나 curl 같은 도구로 재귀적으로 다운로드할 수 있는 백업 디렉터리의 URL입니다.", + "BackupBearerTokenInfo": "백업에 접근하려면 Bearer 토큰이 필요합니다.", + "BackupSnapshots": "백업 스냅샷", + "BackupFileDownload": "파일 다운로드", + "BackupFiles": "백업 파일", + "BackupNoBackup": "현재 사용 가능한 백업이 없습니다.", + "BackupDownloadAll": "전체 백업 다운로드", + "BackupPreparingDownload": "백업 준비 중…", + "BackupDownloadAllInfo": "모든 백업 파일을 컴퓨터에 보관할 수 있는 단일 .zip 아카이브로 다운로드합니다.", + "BackupCopyScript": "다운로드 스크립트 복사", + "BackupCopyToken": "토큰 복사", + "BackupScriptInfo": "curl로 모든 백업 파일을 다운로드하는 셸 스크립트입니다. 저장한 후 터미널에서 실행하세요. 백업 토큰을 입력하라는 메시지가 표시되므로 스크립트에 비밀 정보가 저장되지 않습니다.", + "BackupRestoreGuide": "백업 및 복원 가이드", + "BackupRestoreGuideInfo": "이 백업을 다운로드하여 다른 Huly 인스턴스로 복원하는 단계별 안내입니다.", + "NonBackupedBlobs": "백업되지 않은 Blob", + "Calendar": "캘린더", + "StartOfTheWeek": "주 시작일", + "SystemSetupString": "시스템 설정 ({day})", + "DefaultString": "기본값 ({day})", + "AddAttribute": "속성 추가", + "WorkspaceNamePattern": "이름은 40자 이하여야 하며, 비워둘 수 없고 특수 문자(<, >, /)를 포함할 수 없습니다", + "Mailboxes": "메일함", + "CreateMailbox": "메일함 생성", + "CreateMailboxPlaceholder": "my-cool-name", + "MailboxNoDomains": "이메일 도메인이 구성되지 않았습니다", + "MailboxLimitReached": "메일함 한도에 도달했습니다", + "MailboxErrorInvalidName": "메일함 이름이 유효하지 않습니다", + "MailboxErrorDomainNotFound": "도메인을 찾을 수 없습니다", + "MailboxErrorNameRulesViolated": "메일함 이름은 {minLen}~{maxLen}자여야 합니다", + "MailboxErrorMailboxExists": "이미 사용 중인 메일함 이름입니다", + "MailboxErrorMailboxCountLimit": "계정의 메일함 개수 한도에 도달했습니다", + "DeleteMailbox": "메일함 삭제", + "MailboxDeleteConfirmation": "이 메일함을 삭제하시겠습니까?", + "DisablePermissions": "역할 기반 접근 제어 비활성화", + "EnablePermissions": "역할 기반 접근 제어 활성화", + "DisablePermissionsConfirmation": "역할 기반 접근 제어를 비활성화하시겠습니까? 모든 역할과 권한이 비활성화됩니다.", + "EnablePermissionsConfirmation": "역할 기반 접근 제어를 활성화하시겠습니까? 모든 역할과 권한이 활성화됩니다.", + "BetaWarning": "베타로 표시된 모듈은 실험용이며 완전히 작동하지 않을 수 있습니다. 현재로서는 중요한 작업에 베타 기능을 사용하는 것을 권장하지 않습니다.", + "IntegrationFailed": "연동 생성에 실패했습니다", + "IntegrationError": "다시 시도하거나, 문제가 지속되면 지원팀에 문의하세요", + "EmailIsUsed": "이미 다른 계정에서 사용 중인 이메일 주소입니다", + "Customize": "사용자 정의", + "GuestAccess": "익명 게스트", + "GuestAccessDescription": "익명 사용자가 워크스페이스를 읽기 전용 모드로 방문할 수 있도록 허용", + "GuestSignUpDescription": "익명 사용자가 제한된 편집 권한의 게스트로 워크스페이스에 참여할 수 있도록 허용", + "GuestChannelsDescription": "참여 후 게스트가 메시지를 작성할 수 있는 채널", + "GuestChannelsArrayLabel": "채널 선택", + "GuestSelectSpaces": "스페이스 선택", + "GuestAutoJoinAvailableSpaces": "자동 참여 스페이스", + "GuestAutoJoinAvailableSpacesHint": "각 애플리케이션 카드에는 \"자동 참여 스페이스\" 행이 있습니다. 워크스페이스 게스트가 활성화될 때 추가될 위치를 선택하세요. 변경 사항은 즉시 적용됩니다.", + "GuestAnonymousVisibleSpaces": "익명 사용자에게 표시되는 스페이스", + "GuestAnonymousVisibleSpacesHint": "각 애플리케이션 카드에는 자체 행이 있습니다. 읽기 전용 익명 계정이 멤버로 추가되는 스페이스를 선택하면 계정이 없는 방문자도 해당 스페이스를 열 수 있습니다. 변경 사항은 즉시 적용됩니다.", + "ManageIdentities": "ID 관리", + "Release": "해제", + "ReleaseSocialId": "소셜 ID 해제", + "ReleaseSocialIdConfirm": "이 소셜 ID({socialId})를 해제하시겠습니까? 계정에서 제거되며 더 이상 로그인에 사용할 수 없습니다. 또한 관련된 모든 연동도 제거됩니다.", + "ReleasePrimarySocialId": "기본 소셜 ID 해제", + "ReleasePrimarySocialIdConfirm": "현재 기본 소셜 ID를 해제하려면 페이지를 새로고침해야 합니다. 계속하시겠습니까?", + "Login": "로그인", + "Primary": "기본", + "MyIntegrations": "내 연동", + "AllIntegrations": "전체", + "ConnectedIntegrations": "연동됨", + "AvailableIntegrations": "사용 가능", + "Connect": "연결", + "Integrate": "연동", + "FailedToLoadIntegrations": "연동을 불러오는 데 실패했습니다", + "FailedToDisconnect": "연동 연결 해제에 실패했습니다", + "ServiceIsUnavailable": "서비스를 사용할 수 없습니다", + "Integrated": "연동됨", + "Connected": "연결됨", + "Disconnected": "연결 해제됨", + "Available": "사용 가능", + "NotConnectedIntegration": "{account} 계정이 워크스페이스와 연동되어 있지 않습니다", + "IntegrationIsUnstable": "연동 서비스에 문제가 발생했습니다. 일부 기능이 제대로 작동하지 않을 수 있습니다.", + "MinValue": "최솟값", + "MaxValue": "최댓값", + "IntegerOnly": "정수만", + "AccessControl": "접근 제어", + "DangerZone": "위험 구역", + "IdentifierExists": "이미 존재하는 식별자입니다", + "Reset": "재설정", + "Restricted": "제한됨", + "RestrictedAttributeWarning": "이 속성의 변경을 제한하시겠습니까? 이 속성에 대한 권한이 생성되며 작업은 되돌릴 수 없습니다.", + "PasswordAgingRule": "비밀번호 만료 규칙", + "PasswordAgingRuleDescription": "사용자가 비밀번호를 변경해야 하는 일수", + "OfficeSettings": "오피스 설정", + "OfficeDefaultSettings": "회의실 기본 설정", + "DefaultStartWithTranscription": "새 오피스 회의실에서 자동 기록 활성화", + "DefaultStartWithRecording": "새 오피스 회의실에서 녹화 활성화", + "GuestPermissionsSettings": "게스트", + "GuestPermissionsApplicationPermissions": "애플리케이션 권한", + "GuestPermissionsApplicationPermissionsHint": "게스트가 사용할 수 있는 애플리케이션을 선택한 다음, 아래에서 각 애플리케이션의 권한을 조정하세요.", + "GuestPermissionsTabGuest": "게스트", + "GuestPermissionsTabAnonymousGuest": "익명 게스트", + "GuestPermissionsAnonymousApplicationHint": "익명(읽기 전용) 게스트의 애플리케이션 접근 권한입니다. 표시되는 애플리케이션은 배포 구성에 따라 달라질 수 있습니다.", + "ImportDocumentPermission": "문서 가져오기", + "ImportDocumentDescription": "사용자에게 워크스페이스로 문서를 가져올 권한을 부여", + "SelectUsers": "사용자 선택", + "ShowInTitle": "제목에 표시", + "SpaceMembersOnly": "스페이스 멤버 전용", + "Security": "보안", + "TwoFactorAuth": "2단계 인증", + "TwoFactorAuthDescription": "2단계 인증은 계정에 추가적인 보안 계층을 더해 줍니다", + "EnableTwoFactorAuth": "2단계 인증 활성화", + "DisableTwoFactorAuth": "2단계 인증 비활성화", + "TwoFactorAuthEnabled": "2단계 인증이 활성화됨", + "TwoFactorAuthDisabled": "2단계 인증이 비활성화됨", + "ShowQRCode": "QR 코드 표시", + "EnterVerificationCode": "인증 코드 입력", + "OverrideAttribute": "속성 재정의", + "Required": "필수", + "ApiTokenStatusActive": "활성", + "ApiTokenStatusExpiring": "만료 예정", + "ApiTokenStatusRevoked": "취소됨", + "ApiTokenStatusExpired": "만료됨", + "ApiTokenExpiry7Days": "7일", + "ApiTokenExpiry30Days": "30일", + "ApiTokenExpiry90Days": "90일", + "ApiTokenExpiry180Days": "180일", + "ApiTokenExpiry365Days": "365일", + "ApiTokenLoadError": "API 토큰을 불러오지 못했습니다", + "ApiTokenCreateError": "토큰 생성에 실패했습니다. 다시 시도해 주세요.", + "ApiTokens": "API 토큰", + "CreateApiToken": "토큰 생성", + "ApiTokenName": "토큰 이름", + "ApiTokenExpiry": "만료", + "ApiTokenCreated": "토큰이 생성됨", + "ApiTokenRevoke": "토큰 취소", + "ApiTokenRevokeConfirm": "이 토큰을 취소하시겠습니까? 더 이상 API 접근에 사용할 수 없습니다.", + "ApiTokenCopyWarning": "지금 이 토큰을 복사하세요. 다시 확인할 수 없습니다.", + "ApiTokenNoTokens": "아직 API 토큰이 없습니다", + "ApiTokenWorkspace": "워크스페이스", + "Created": "생성일", + "Expires": "만료일", + "TokenStatus": "상태", + "ApiUsageTitle": "REST API 사용", + "ApiUsageDescription": "내장 REST API에서 API 토큰을 사용하여 워크스페이스 데이터를 조회하고 수정할 수 있습니다. Authorization 헤더에 Bearer 토큰으로 전달하세요.", + "ApiEndpointPing": "상태 확인", + "ApiEndpointFindAll": "클래스별 문서 조회", + "ApiEndpointFindAllPost": "필터로 조회 (JSON 본문)", + "ApiEndpointTx": "문서 생성 또는 수정", + "ApiEndpointLoadModel": "데이터 모델 불러오기", + "ApiEndpointAccount": "계정 정보 가져오기", + "ApiBaseUrl": "기본 URL", + "ApiWorkspaceId": "워크스페이스 ID(UUID)는 토큰에 포함되어 있습니다. URL에서 :workspaceId로 전달하세요.", + "ApiTokenRevokeError": "토큰 취소에 실패했습니다. 다시 시도해 주세요." + } } diff --git a/plugins/setting-assets/lang/pl.json b/plugins/setting-assets/lang/pl.json index afdf4caee63..575890db1b4 100644 --- a/plugins/setting-assets/lang/pl.json +++ b/plugins/setting-assets/lang/pl.json @@ -215,9 +215,6 @@ "IntegerOnly": "Tylko liczby całkowite", "AccessControl": "Kontrola dostępu", "DangerZone": "Strefa wrażliwa", - "ApiAccess": "Dostęp API", - "ApiToken": "Token API", - "GenerateApiToken": "Wygeneruj token", "IdentifierExists": "Identyfikator już istnieje", "Reset": "Resetuj", "Restricted": "Ograniczono", @@ -249,6 +246,41 @@ "ShowQRCode": "Pokaż kod QR", "EnterVerificationCode": "Wpisz kod weryfikacyjny", "OverrideAttribute": "Nadpisz atrybut", - "Required": "Obowiązkowe" + "Required": "Obowiązkowe", + "ApiTokenStatusActive": "Aktywny", + "ApiTokenStatusExpiring": "Wygasa", + "ApiTokenStatusRevoked": "Unieważniony", + "ApiTokenStatusExpired": "Wygasł", + "ApiTokenExpiry7Days": "7 dni", + "ApiTokenExpiry30Days": "30 dni", + "ApiTokenExpiry90Days": "90 dni", + "ApiTokenExpiry180Days": "180 dni", + "ApiTokenExpiry365Days": "365 dni", + "ApiTokenLoadError": "Nie udało się wczytać tokenów API", + "ApiTokenCreateError": "Nie udało się utworzyć tokenu. Spróbuj ponownie.", + "ApiTokens": "Tokeny API", + "CreateApiToken": "Utwórz token", + "ApiTokenName": "Nazwa tokenu", + "ApiTokenExpiry": "Wygaśnięcie", + "ApiTokenCreated": "Token utworzony", + "ApiTokenRevoke": "Unieważnij token", + "ApiTokenRevokeConfirm": "Czy na pewno chcesz unieważnić ten token? Nie będzie już można go użyć do dostępu przez API.", + "ApiTokenCopyWarning": "Skopiuj ten token teraz. Nie będzie można go ponownie zobaczyć.", + "ApiTokenNoTokens": "Brak tokenów API", + "ApiTokenWorkspace": "Przestrzeń robocza", + "Created": "Utworzono", + "Expires": "Wygasa", + "TokenStatus": "Status", + "ApiUsageTitle": "Korzystanie z REST API", + "ApiUsageDescription": "Użyj tokenu API z wbudowanym REST API, aby odpytywać i modyfikować dane przestrzeni roboczej. Przekaż token jako Bearer w nagłówku Authorization.", + "ApiEndpointPing": "Kontrola stanu", + "ApiEndpointFindAll": "Zapytanie o dokumenty wg klasy", + "ApiEndpointFindAllPost": "Zapytanie z filtrami (treść JSON)", + "ApiEndpointTx": "Tworzenie lub aktualizacja dokumentów", + "ApiEndpointLoadModel": "Wczytaj model danych", + "ApiEndpointAccount": "Pobierz informacje o koncie", + "ApiBaseUrl": "Bazowy adres URL", + "ApiWorkspaceId": "Identyfikator przestrzeni roboczej (UUID) jest zawarty w tokenie. Przekaż go jako :workspaceId w adresie URL.", + "ApiTokenRevokeError": "Nie udało się unieważnić tokenu. Spróbuj ponownie." } } diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index 35fe91b5d6e..5942268930d 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -208,9 +208,6 @@ "IntegerOnly": "Apenas números inteiros", "AccessControl": "Controle de acesso", "DangerZone": "Zona de perigo", - "ApiAccess": "Acesso à API", - "ApiToken": "Token de API", - "GenerateApiToken": "Gerar token de API", "IdentifierExists": "Identificador já existe", "PasswordAgingRule": "Regra de envelhecimento de senha", "PasswordAgingRuleDescription": "Número de dias após os quais os usuários serão obrigados a alterar sua senha.", @@ -240,6 +237,50 @@ "ShowQRCode": "Mostrar código QR", "EnterVerificationCode": "Inserir código de verificação", "OverrideAttribute": "Sobrescrever atributo", - "Required": "Obrigatório" + "Required": "Obrigatório", + "ApiBaseUrl": "URL base", + "ApiEndpointAccount": "Obter informações da conta", + "ApiEndpointFindAll": "Consultar documentos por classe", + "ApiEndpointFindAllPost": "Consulta com filtros (corpo JSON)", + "ApiEndpointLoadModel": "Carregar o modelo de dados", + "ApiEndpointPing": "Verificação de integridade", + "ApiEndpointTx": "Criar ou atualizar documentos", + "ApiTokenCopyWarning": "Copie este token agora. Você não poderá vê-lo novamente.", + "ApiTokenCreated": "Token criado", + "ApiTokenExpiry": "Expiração", + "ApiTokenName": "Nome do token", + "ApiTokenNoTokens": "Ainda não há tokens de API", + "ApiTokenRevoke": "Revogar token", + "ApiTokenRevokeConfirm": "Tem certeza de que deseja revogar este token? Ele não poderá mais ser usado para acesso à API.", + "ApiTokenWorkspace": "Espaço de trabalho", + "ApiTokens": "Tokens de API", + "ApiUsageDescription": "Use seu token de API com a API REST integrada para consultar e modificar os dados do espaço de trabalho. Passe o token como token Bearer no cabeçalho Authorization.", + "ApiUsageTitle": "Usando a API REST", + "ApiWorkspaceId": "O ID do seu espaço de trabalho (UUID) está incluído no token. Passe-o como :workspaceId na URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Criar token", + "Created": "Criado", + "Description": "Description", + "Expires": "Expira", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Status", + "ApiTokenStatusActive": "Ativo", + "ApiTokenStatusExpiring": "Expirando", + "ApiTokenStatusRevoked": "Revogado", + "ApiTokenStatusExpired": "Expirado", + "ApiTokenExpiry7Days": "7 dias", + "ApiTokenExpiry30Days": "30 dias", + "ApiTokenExpiry90Days": "90 dias", + "ApiTokenExpiry180Days": "180 dias", + "ApiTokenExpiry365Days": "365 dias", + "ApiTokenLoadError": "Falha ao carregar os tokens de API", + "ApiTokenCreateError": "Falha ao criar o token. Tente novamente.", + "ApiTokenRevokeError": "Falha ao revogar o token. Tente novamente." } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 50d8f10a37a..27330bffa66 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -208,9 +208,6 @@ "IntegerOnly": "Apenas números inteiros", "AccessControl": "Controle de acesso", "DangerZone": "Zona de perigo", - "ApiAccess": "Acesso à API", - "ApiToken": "Token de API", - "GenerateApiToken": "Gerar token de API", "IdentifierExists": "Identificador já existe", "PasswordAgingRule": "Regra de envelhecimento de senha", "PasswordAgingRuleDescription": "Número de dias após os quais os usuários serão obrigados a alterar sua senha.", @@ -240,6 +237,50 @@ "ShowQRCode": "Mostrar código QR", "EnterVerificationCode": "Inserir código de verificação", "OverrideAttribute": "Sobrescrever atributo", - "Required": "Obrigatório" + "Required": "Obrigatório", + "ApiBaseUrl": "URL base", + "ApiEndpointAccount": "Obter informações da conta", + "ApiEndpointFindAll": "Consultar documentos por classe", + "ApiEndpointFindAllPost": "Consulta com filtros (corpo JSON)", + "ApiEndpointLoadModel": "Carregar o modelo de dados", + "ApiEndpointPing": "Verificação de estado", + "ApiEndpointTx": "Criar ou atualizar documentos", + "ApiTokenCopyWarning": "Copie este token agora. Não o poderá ver novamente.", + "ApiTokenCreated": "Token criado", + "ApiTokenExpiry": "Expiração", + "ApiTokenName": "Nome do token", + "ApiTokenNoTokens": "Ainda não existem tokens de API", + "ApiTokenRevoke": "Revogar token", + "ApiTokenRevokeConfirm": "Tem a certeza de que pretende revogar este token? Deixará de poder ser utilizado para acesso à API.", + "ApiTokenWorkspace": "Espaço de trabalho", + "ApiTokens": "Tokens de API", + "ApiUsageDescription": "Utilize o seu token de API com a API REST integrada para consultar e modificar os dados do espaço de trabalho. Passe o token como token Bearer no cabeçalho Authorization.", + "ApiUsageTitle": "Utilização da API REST", + "ApiWorkspaceId": "O ID do seu espaço de trabalho (UUID) está incluído no token. Passe-o como :workspaceId no URL.", + "CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}", + "CreateApiToken": "Criar token", + "Created": "Criado", + "Description": "Description", + "Expires": "Expira", + "General": "General", + "NewSpaceType": "New space type", + "Permissions": "Permissions", + "RoleName": "Role name", + "Roles": "Roles", + "SpaceTypeTitle": "Space type title", + "SpaceTypes": "Space types", + "TokenStatus": "Estado", + "ApiTokenStatusActive": "Ativo", + "ApiTokenStatusExpiring": "A expirar", + "ApiTokenStatusRevoked": "Revogado", + "ApiTokenStatusExpired": "Expirado", + "ApiTokenExpiry7Days": "7 dias", + "ApiTokenExpiry30Days": "30 dias", + "ApiTokenExpiry90Days": "90 dias", + "ApiTokenExpiry180Days": "180 dias", + "ApiTokenExpiry365Days": "365 dias", + "ApiTokenLoadError": "Falha ao carregar os tokens de API", + "ApiTokenCreateError": "Falha ao criar o token. Tente novamente.", + "ApiTokenRevokeError": "Falha ao revogar o token. Tente novamente." } } diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 677a522cc72..4a70535508c 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -217,9 +217,6 @@ "IntegrationIsUnstable": "Сервис интеграции испытывает проблемы. Некоторые функции могут работать некорректно.", "AccessControl": "Контроль доступа", "DangerZone": "Опасная зона", - "ApiAccess": "Доступ к API", - "ApiToken": "API токен", - "GenerateApiToken": "Создать API токен", "Restricted": "Ограничено", "RestrictedAttributeWarning": "Вы действительно хотите ограничить изменение атрибута? Это действие создаст разрешения для этого атрибута. Отменить это действие невозможно.", "PasswordAgingRule": "Правило устаревания пароля", @@ -249,6 +246,41 @@ "ShowQRCode": "Показать QR-код", "EnterVerificationCode": "Введите код подтверждения", "OverrideAttribute": "Переопределить атрибут", - "Required": "Обязательный" + "Required": "Обязательный", + "ApiBaseUrl": "Базовый URL", + "ApiEndpointAccount": "Получить информацию об аккаунте", + "ApiEndpointFindAll": "Запрос документов по классу", + "ApiEndpointFindAllPost": "Запрос с фильтрами (тело JSON)", + "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": "Статус", + "ApiTokenRevokeError": "Не удалось отозвать токен. Попробуйте ещё раз." } } diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index 0a883da16ac..241f718e22f 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -217,9 +217,6 @@ "IntegerOnly": "Sadece tam sayılar", "AccessControl": "Erişim kontrolü", "DangerZone": "Tehlike bölgesi", - "ApiAccess": "API erişimi", - "ApiToken": "API token", - "GenerateApiToken": "API token oluştur", "IdentifierExists": "Tanımlayıcı zaten mevcut", "PasswordAgingRule": "Parola yaşlandırma kuralı", "PasswordAgingRuleDescription": "Kullanıcıların parolalarını değiştirmeleri gerekecek gün sayısı", @@ -249,6 +246,41 @@ "ShowQRCode": "QR kodu göster", "EnterVerificationCode": "Doğrulama kodunu gir", "OverrideAttribute": "Özniteliği geçersiz kıl", - "Required": "Zorunlu" + "Required": "Zorunlu", + "ApiBaseUrl": "Temel URL", + "ApiEndpointAccount": "Hesap bilgilerini al", + "ApiEndpointFindAll": "Belgeleri sınıfa göre sorgula", + "ApiEndpointFindAllPost": "Filtrelerle sorgu (JSON gövdesi)", + "ApiEndpointLoadModel": "Veri modelini yükle", + "ApiEndpointPing": "Sağlık kontrolü", + "ApiEndpointTx": "Belge oluştur veya güncelle", + "ApiTokenCopyWarning": "Bu belirteci şimdi kopyalayın. Daha sonra tekrar göremezsiniz.", + "ApiTokenCreated": "Belirteç oluşturuldu", + "ApiTokenExpiry": "Son kullanma", + "ApiTokenName": "Belirteç adı", + "ApiTokenNoTokens": "Henüz API belirteci yok", + "ApiTokenRevoke": "Belirteci iptal et", + "ApiTokenRevokeConfirm": "Bu belirteci iptal etmek istediğinize emin misiniz? Artık API erişimi için kullanılamayacak.", + "ApiTokenWorkspace": "Çalışma alanı", + "ApiTokens": "API Belirteçleri", + "ApiUsageDescription": "Çalışma alanı verilerini sorgulamak ve değiştirmek için API belirtecinizi yerleşik REST API ile kullanın. Belirteci Authorization başlığında Bearer belirteci olarak iletin.", + "ApiUsageTitle": "REST API kullanımı", + "ApiWorkspaceId": "Çalışma alanı kimliğiniz (UUID) belirtece dahildir. URL'de :workspaceId olarak iletin.", + "CreateApiToken": "Belirteç oluştur", + "Created": "Oluşturuldu", + "Expires": "Sona eriyor", + "TokenStatus": "Durum", + "ApiTokenStatusActive": "Etkin", + "ApiTokenStatusExpiring": "Süresi doluyor", + "ApiTokenStatusRevoked": "İptal edildi", + "ApiTokenStatusExpired": "Süresi doldu", + "ApiTokenExpiry7Days": "7 gün", + "ApiTokenExpiry30Days": "30 gün", + "ApiTokenExpiry90Days": "90 gün", + "ApiTokenExpiry180Days": "180 gün", + "ApiTokenExpiry365Days": "365 gün", + "ApiTokenLoadError": "API belirteçleri yüklenemedi", + "ApiTokenCreateError": "Belirteç oluşturulamadı. Lütfen tekrar deneyin.", + "ApiTokenRevokeError": "Belirteç iptal edilemedi. Lütfen tekrar deneyin." } -} \ No newline at end of file +} diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index f1b43b18961..cb1fe9fc184 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -217,9 +217,6 @@ "IntegerOnly": "仅整数", "AccessControl": "访问控制", "DangerZone": "危险区域", - "ApiAccess": "API访问", - "ApiToken": "API令牌", - "GenerateApiToken": "生成API令牌", "IdentifierExists": "标识符已存在", "PasswordAgingRule": "密码老化规则", "PasswordAgingRuleDescription": "用户需要更改密码的天数", @@ -249,6 +246,41 @@ "ShowQRCode": "显示QR码", "EnterVerificationCode": "输入验证码", "OverrideAttribute": "覆盖属性", - "Required": "必须" + "Required": "必须", + "ApiBaseUrl": "基础 URL", + "ApiEndpointAccount": "获取账户信息", + "ApiEndpointFindAll": "按类查询文档", + "ApiEndpointFindAllPost": "带过滤条件查询(JSON 请求体)", + "ApiEndpointLoadModel": "加载数据模型", + "ApiEndpointPing": "健康检查", + "ApiEndpointTx": "创建或更新文档", + "ApiTokenCopyWarning": "请立即复制此令牌,之后将无法再次查看。", + "ApiTokenCreated": "令牌已创建", + "ApiTokenExpiry": "有效期", + "ApiTokenName": "令牌名称", + "ApiTokenNoTokens": "暂无 API 令牌", + "ApiTokenRevoke": "撤销令牌", + "ApiTokenRevokeConfirm": "确定要撤销此令牌吗?撤销后将无法再用于 API 访问。", + "ApiTokenWorkspace": "工作区", + "ApiTokens": "API 令牌", + "ApiUsageDescription": "将您的 API 令牌与内置 REST API 配合使用,以查询和修改工作区数据。在 Authorization 标头中以 Bearer 令牌形式传递该令牌。", + "ApiUsageTitle": "使用 REST API", + "ApiWorkspaceId": "您的工作区 ID(UUID)已包含在令牌中。在 URL 中将其作为 :workspaceId 传递。", + "CreateApiToken": "创建令牌", + "Created": "创建于", + "Expires": "过期时间", + "TokenStatus": "状态", + "ApiTokenStatusActive": "有效", + "ApiTokenStatusExpiring": "即将过期", + "ApiTokenStatusRevoked": "已撤销", + "ApiTokenStatusExpired": "已过期", + "ApiTokenExpiry7Days": "7 天", + "ApiTokenExpiry30Days": "30 天", + "ApiTokenExpiry90Days": "90 天", + "ApiTokenExpiry180Days": "180 天", + "ApiTokenExpiry365Days": "365 天", + "ApiTokenLoadError": "加载 API 令牌失败", + "ApiTokenCreateError": "创建令牌失败,请重试。", + "ApiTokenRevokeError": "撤销令牌失败,请重试。" } } 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..09b958c9d31 --- /dev/null +++ b/plugins/setting-resources/src/components/ApiDocsSection.svelte @@ -0,0 +1,252 @@ + + + +
+ + {#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 + +
+
+ +
+ +
 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..c6ab4412503 --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokenCreatePopup.svelte @@ -0,0 +1,192 @@ + + + + { + dispatch('close', createdToken !== undefined) + }} +> + {#if createdToken !== undefined} +
+ +
{ + if (e.key === 'Enter' || e.key === ' ') copyToken() + }} + > + {createdToken} +
+
+ {:else} +
+ +
+
+ + +
+
+ + { + selectedExpiry = e.detail + }} + /> +
+ {#if error !== undefined} +
+ {/if} + {/if} +
+ + diff --git a/plugins/setting-resources/src/components/ApiTokenPopup.svelte b/plugins/setting-resources/src/components/ApiTokenPopup.svelte deleted file mode 100644 index d4b48d2c400..00000000000 --- a/plugins/setting-resources/src/components/ApiTokenPopup.svelte +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - diff --git a/plugins/setting-resources/src/components/ApiTokens.svelte b/plugins/setting-resources/src/components/ApiTokens.svelte new file mode 100644 index 00000000000..830d9157530 --- /dev/null +++ b/plugins/setting-resources/src/components/ApiTokens.svelte @@ -0,0 +1,219 @@ + + + +
+
+ + + + +
+
+
+ {#if loading} + + {:else if loadError} +
+
+ {:else if revokeError} +
+
+ {: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/components/General.svelte b/plugins/setting-resources/src/components/General.svelte index f65b08c947b..8cd1e8c18c6 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 @@ -153,11 +152,6 @@ await accountClient.updatePasswordAgingRule(passwordAgingRule) } - async function handleGenerateApiToken (): Promise { - const { token } = await accountClient.selectWorkspace(workspaceUrl) - showPopup(ApiTokenPopup, { token }) - } - function handleTogglePermissions (): void { const newState = !arePermissionsDisabled showPopup(MessageBox, { @@ -318,19 +312,6 @@ allowGuests={true} /> -
-
-
-
-
-
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-resources/src/plugin.ts b/plugins/setting-resources/src/plugin.ts index c39657ba615..e512016b44a 100644 --- a/plugins/setting-resources/src/plugin.ts +++ b/plugins/setting-resources/src/plugin.ts @@ -142,9 +142,6 @@ export default mergeIds(settingId, setting, { GuestAutoJoinAvailableSpacesHint: '' as IntlString, GuestAnonymousVisibleSpaces: '' as IntlString, GuestAnonymousVisibleSpacesHint: '' as IntlString, - ApiAccess: '' as IntlString, - ApiToken: '' as IntlString, - GenerateApiToken: '' as IntlString, ImportDocumentPermission: '' as IntlString, ImportDocumentDescription: '' as IntlString, SelectUsers: '' as IntlString, diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 76317f3d8e9..bf8d3ef6a0a 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, @@ -361,7 +363,42 @@ 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, + ApiTokenRevokeError: '' 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, @@ -383,7 +420,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/pods/server/src/rpc.ts b/pods/server/src/rpc.ts index 125a22df369..91913e20f4e 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 { setApiTokenRevocationChecker, verifyToken, type Token } from '@hcengineering/server-token' import { createHash } from 'crypto' import { type Express, type Response as ExpressResponse, type Request } from 'express' @@ -136,6 +136,22 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur return getAccountClientRaw(accountsUrl, token) } + // Centralized revocation resolution for verifyToken: the account is the source + // of truth, so we simply ask it to validate the presenter's own token via an + // existing method. A rejection (Unauthorized) means revoked or expired; any + // other failure is transient and left for verifyToken's cache to retry. + setApiTokenRevocationChecker(async (_apiTokenId, _token, raw) => { + try { + await getAccountClient(raw).getLoginInfoByToken() + return false + } catch (err: any) { + if (err instanceof PlatformError && err.status?.code === platform.status.Unauthorized) { + return true + } + throw err + } + }) + async function withSession ( req: Request, res: ExpressResponse, @@ -161,7 +177,17 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur const workspaceId = decodeURIComponent(req.params.workspaceId) token = token.split(' ')[1] - const decodedToken = decodeToken(token) + // Verify signature, expiry, and (for revokable API tokens) revocation. + let decodedToken: Token + try { + decodedToken = await verifyToken(token) + } catch (err: any) { + // Keep the response opaque, but leave operators something to debug with: + // expired, revoked and unverifiable all look identical from outside. + ctx.warn('REST token rejected', { method, error: err?.message }) + sendError(res, 401, { message: 'Invalid or revoked token' }) + return + } if (workspaceId !== decodedToken.workspace) { sendError(res, 403, { message: 'Invalid workspace', workspace: decodedToken.workspace }) return @@ -267,7 +293,7 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur }) app.post('/api/v1/tx/:workspaceId', (req, res) => { - void withSession(req, res, 'tx', async (ctx, session, rateLimit) => { + void withSession(req, res, 'tx', async (ctx, session, rateLimit, token) => { const tx: any = (await retrieveJson(req)) ?? {} try { diff --git a/pods/server/src/server_http.ts b/pods/server/src/server_http.ts index c1469b8ae8a..8c0b66bd47f 100644 --- a/pods/server/src/server_http.ts +++ b/pods/server/src/server_http.ts @@ -515,6 +515,7 @@ export function startHttpServer ( }, 1000) } if ('upgrade' in s) { + // 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(() => { diff --git a/server/account/src/__tests__/apiTokens.test.ts b/server/account/src/__tests__/apiTokens.test.ts new file mode 100644 index 00000000000..87b4772718a --- /dev/null +++ b/server/account/src/__tests__/apiTokens.test.ts @@ -0,0 +1,265 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { AccountRole, type MeasureContext, type PersonUuid, type WorkspaceUuid } from '@hcengineering/core' +import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token' + +import { type AccountDB } from '../types' +import { getMethods } from '../operations' + +jest.mock('@hcengineering/platform', () => { + const actual = jest.requireActual('@hcengineering/platform') + return { + ...actual, + ...actual.default, + getMetadata: jest.fn(), + translate: jest.fn((id, params) => `${id} << ${JSON.stringify(params)}`) + } +}) + +jest.mock('@hcengineering/server-token', () => { + class TokenError extends Error { + constructor (msg: string) { + super(msg) + this.name = 'TokenError' + } + } + return { + decodeTokenVerbose: jest.fn(), + decodeToken: jest.fn(), + TokenError, + generateToken: jest.fn().mockImplementation((account: string, workspace: string, extra: any) => { + return `mocked-token-${account}-${workspace}-${JSON.stringify(extra)}` + }) + } +}) + +describe('API tokens', () => { + const mockCtx = { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() + } as unknown as MeasureContext + + const accountUuid = 'account-uuid' as PersonUuid + const workspaceUuid = 'workspace-uuid' as WorkspaceUuid + const validParams = { name: 'test', workspaceUuid, expiryDays: 30 } + + let mockDb: AccountDB + + const methods = getMethods() + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + const createApiToken = methods.createApiToken! + const listApiTokens = methods.listApiTokens! + const revokeApiToken = methods.revokeApiToken! + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + + const token = (extra: Record = {}): void => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: accountUuid, workspace: workspaceUuid, extra }) + } + + beforeEach(() => { + jest.clearAllMocks() + mockDb = { + account: { findOne: jest.fn() }, + workspace: { find: jest.fn().mockResolvedValue([{ uuid: workspaceUuid, name: 'Test' }]) }, + apiToken: { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + insertOne: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined) + }, + getWorkspaceRole: jest.fn().mockResolvedValue(AccountRole.Owner) + } as unknown as AccountDB + token() + }) + + describe('createApiToken', () => { + test('creates a token for a workspace member', async () => { + const result = await createApiToken(mockCtx, mockDb, null, { id: 1, params: validParams }, 'test-token') + + expect(result.result.id).toBeDefined() + expect(result.result.token).toContain('mocked-token') + expect(mockDb.apiToken.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ accountUuid, workspaceUuid, revoked: false }) + ) + }) + + test('embeds the token id so it can be revoked later', async () => { + await createApiToken(mockCtx, mockDb, null, { id: 1, params: validParams }, 'test-token') + + const [, , extra] = (generateToken as jest.Mock).mock.calls[0] + const inserted = (mockDb.apiToken.insertOne as jest.Mock).mock.calls[0][0] + expect(extra).toEqual({ apiTokenId: inserted.id }) + }) + + test('rejects a guest', async () => { + ;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Guest) + + const result = await createApiToken(mockCtx, mockDb, null, { id: 1, params: validParams }, 'test-token') + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.insertOne).not.toHaveBeenCalled() + }) + + test('rejects a non-member', async () => { + ;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(null) + + const result = await createApiToken(mockCtx, mockDb, null, { id: 1, params: validParams }, 'test-token') + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.insertOne).not.toHaveBeenCalled() + }) + + test('rejects a caller presenting an API token', async () => { + token({ apiTokenId: 'some-token' }) + + const result = await createApiToken(mockCtx, mockDb, null, { id: 1, params: validParams }, 'test-token') + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.insertOne).not.toHaveBeenCalled() + }) + + test.each([ + ['expiry below range', { ...validParams, expiryDays: 0 }], + ['expiry above range', { ...validParams, expiryDays: 366 }], + ['expiry not a number', { ...validParams, expiryDays: 'thirty' }], + ['empty name', { ...validParams, name: ' ' }], + ['overlong name', { ...validParams, name: 'x'.repeat(256) }], + ['missing workspace', { name: 'test', expiryDays: 30 }] + ])('rejects %s', async (_label, params) => { + const result = await createApiToken(mockCtx, mockDb, null, { id: 1, params }, 'test-token') + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.insertOne).not.toHaveBeenCalled() + }) + + test('counts only usable tokens toward the limit', async () => { + const now = Date.now() + const spent = Array.from({ length: 200 }, (_, i) => ({ + id: `old-${i}`, + revoked: i % 2 === 0, + expiresOn: i % 2 === 0 ? now + 86400000 : now - 1 + })) + ;(mockDb.apiToken.find as jest.Mock).mockResolvedValue(spent) + + const result = await createApiToken(mockCtx, mockDb, null, { id: 1, params: validParams }, 'test-token') + + expect(result.error).toBeUndefined() + expect(mockDb.apiToken.insertOne).toHaveBeenCalled() + }) + + test('refuses once the limit of usable tokens is reached', async () => { + const live = Array.from({ length: 100 }, (_, i) => ({ + id: `live-${i}`, + revoked: false, + expiresOn: Date.now() + 86400000 + })) + ;(mockDb.apiToken.find as jest.Mock).mockResolvedValue(live) + + const result = await createApiToken(mockCtx, mockDb, null, { id: 1, params: validParams }, 'test-token') + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.insertOne).not.toHaveBeenCalled() + }) + }) + + describe('revokeApiToken', () => { + beforeEach(() => { + ;(mockDb.apiToken.findOne as jest.Mock).mockResolvedValue({ + id: 'token-1', + accountUuid, + workspaceUuid, + revoked: false + }) + }) + + test('revokes a token the caller owns', async () => { + const result = await revokeApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { tokenId: 'token-1' } }, + 'test-token' + ) + + expect(result.error).toBeUndefined() + expect(mockDb.apiToken.update).toHaveBeenCalledWith({ id: 'token-1' }, { revoked: true }) + }) + + test('revokes even after the owner left the workspace', async () => { + ;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(null) + + const result = await revokeApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { tokenId: 'token-1' } }, + 'test-token' + ) + + expect(result.error).toBeUndefined() + expect(mockDb.apiToken.update).toHaveBeenCalledWith({ id: 'token-1' }, { revoked: true }) + }) + + test('does not revoke a token belonging to somebody else', async () => { + ;(mockDb.apiToken.findOne as jest.Mock).mockResolvedValue(null) + + const result = await revokeApiToken(mockCtx, mockDb, null, { id: 1, params: { tokenId: 'other' } }, 'test-token') + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.update).not.toHaveBeenCalled() + expect((mockDb.apiToken.findOne as jest.Mock).mock.calls[0][0]).toEqual({ id: 'other', accountUuid }) + }) + + test('rejects a caller presenting an API token', async () => { + token({ apiTokenId: 'token-1' }) + + const result = await revokeApiToken( + mockCtx, + mockDb, + null, + { id: 1, params: { tokenId: 'token-1' } }, + 'test-token' + ) + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.update).not.toHaveBeenCalled() + }) + }) + + describe('listApiTokens', () => { + test('returns the caller tokens with workspace names resolved', async () => { + ;(mockDb.apiToken.find as jest.Mock).mockResolvedValue([ + { id: 'token-1', accountUuid, name: 'CI', workspaceUuid, createdOn: 1000, expiresOn: 2000, revoked: false } + ]) + + const result = await listApiTokens(mockCtx, mockDb, null, { id: 1, params: {} }, 'test-token') + + expect(result.result).toEqual([ + expect.objectContaining({ id: 'token-1', name: 'CI', workspaceName: 'Test', revoked: false }) + ]) + }) + + test('rejects a caller presenting an API token', async () => { + token({ apiTokenId: 'token-1' }) + + const result = await listApiTokens(mockCtx, mockDb, null, { id: 1, params: {} }, 'test-token') + + expect(result.error).toBeDefined() + expect(mockDb.apiToken.find).not.toHaveBeenCalled() + }) + }) +}) diff --git a/server/account/src/collections/mongo.ts b/server/account/src/collections/mongo.ts index 18e0ff33b78..384961c6546 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 e91e883905d..b8fd79c547c 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) ] } @@ -809,3 +810,34 @@ function getV26Migration (ns: string, flavor: DBFlavor): [string, string] { ` ] } + +function getV27Migration (ns: string, flavor: DBFlavor): [string, string] { + const types = dbTypes[flavor] + return [ + 'account_db_v27_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 73e4c1d9c2f..5b5d791491c 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 { @@ -1080,6 +1088,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 3f2f3d5921b..28b85a0e7c7 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -39,6 +39,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' @@ -2669,6 +2670,169 @@ async function deleteMailbox ( ctx.info('Mailbox deleted', { mailbox, account }) } +// ── API Token Management ──────────────────────────────────────────── + +const MAX_TOKENS_PER_ACCOUNT = 100 + +/** + * API tokens carry the full rights of their account, so letting one manage tokens + * would make a leaked token self-renewing: it could mint a fresh token with a new + * expiry, or revoke the tokens its owner would use to cut it off. Token management + * stays with an interactive session. + */ +function verifyNotApiToken (extra: Record | undefined): void { + if (extra?.apiTokenId !== undefined) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } +} + +/** + * 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, extra } = decodeTokenVerbose(ctx, token) + verifyNotApiToken(extra) + + // 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. Revoked and expired tokens are kept for the + // audit trail, so counting them would eventually lock out anyone who rotates. + const now = Date.now() + const existingTokens = await db.apiToken.find({ accountUuid: account }) + const usableTokens = existingTokens.filter((it) => !it.revoked && it.expiresOn > now) + if (usableTokens.length >= MAX_TOKENS_PER_ACCOUNT) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const expiresOn = now + days * 86400000 + const expSec = Math.floor(expiresOn / 1000) + + const id = randomUUID() + const apiToken = generateToken(account, workspaceUuid, { apiTokenId: id }, 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, extra } = decodeTokenVerbose(ctx, token) + verifyNotApiToken(extra) + + 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 + })) +} + +/** + * Revokes one of the caller's own API tokens. The record is kept so the token + * stays visible as revoked rather than silently disappearing. + */ +async function revokeApiToken ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { tokenId: string } +): Promise { + const { account, extra } = decodeTokenVerbose(ctx, token) + verifyNotApiToken(extra) + const { tokenId } = params + + // Scoped to the caller's own tokens, which is the only authority revoking needs. + // Deliberately no workspace role check: leaving a workspace must not strand a + // credential its owner can no longer revoke. + 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', { id: tokenId, account }) +} + async function exchangeGuestToken ( ctx: MeasureContext, db: AccountDB, @@ -3367,6 +3531,9 @@ export type AccountMethods = | 'hasWorkspacePermission' | 'getWorkspacePermissions' | 'getWorkspaceUsersWithPermission' + | 'createApiToken' + | 'listApiTokens' + | 'revokeApiToken' /** * @public @@ -3434,6 +3601,11 @@ export function getMethods (hasSignUp: boolean = true): Partial subscription: DbCollection workspacePermission: DbCollection + apiToken: DbCollection init: () => Promise createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index aacc22811a0..bf36af312c1 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -43,7 +43,13 @@ import otpGenerator from 'otp-generator' import { authenticator } from 'otplib' import { Analytics } from '@hcengineering/analytics' -import { decodeTokenVerbose, generateToken, type PermissionsGrant, TokenError } from '@hcengineering/server-token' +import { + decodeToken, + decodeTokenVerbose, + generateToken, + type PermissionsGrant, + TokenError +} from '@hcengineering/server-token' import { MongoAccountDB } from './collections/mongo' import { PostgresAccountDB } from './collections/postgres/postgres' import { accountPlugin } from './plugin' @@ -183,6 +189,26 @@ export function wrap ( token?: string, meta?: Meta ): Promise { + // The account is the source of truth for API token validity. Reject revoked + // or expired API tokens up front so every method (and any service that + // delegates token verification here) sees a consistent answer. + if (token != null && token !== '') { + const decoded = (() => { + try { + return decodeToken(token) + } catch { + return undefined + } + })() + const apiTokenId = decoded?.extra?.apiTokenId + if (apiTokenId !== undefined) { + const apiToken = await db.apiToken.findOne({ id: apiTokenId }) + if (apiToken == null || apiToken.revoked || apiToken.expiresOn <= Date.now()) { + return { error: new Status(Severity.ERROR, platform.status.Unauthorized, {}) } + } + } + } + return await accountMethod(ctx, db, branding, token, { ...request.params }, meta) .then((result) => ({ id: request.id, result })) .catch((err: Error) => {