mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
feat(account): revocable API tokens managed from account settings (#10624)
* feat: API token management in workspace settings Add UI and backend support for creating, listing, and revoking API tokens scoped to workspaces. Includes owner-level workspace token visibility, OpenAPI documentation, Mongo/Postgres persistence, and i18n translations. Signed-off-by: Don Kendall <kendall@donkendall.com> * feat: enforce API token revocation at transactor level Embed apiTokenId in JWT extra field and add a per-token revocation cache (60s TTL) in the transactor REST handler. Revoked tokens are now rejected within ~60 seconds instead of remaining valid until JWT expiry. Adds checkApiTokenRevoked account service method for the transactor to query individual token revocation status. Signed-off-by: Don Kendall <kendall@donkendall.com> * feat: implement Phase 1 API token scopes (read/write/delete) Add coarse-grained scope enforcement for API tokens. Tokens can now be created with scopes ['read:*'], ['read:*','write:*'], or ['read:*','write:*','delete:*']. Existing tokens without scopes retain full access (backward compatible). - DB: v26 migration adds scopes TEXT[] column to api_tokens - Types: add scopes field to ApiToken and ApiTokenInfo - Operations: createApiToken accepts/validates/persists scopes, embeds in JWT via extra.scopes - Enforcement: withSession checks scopes against method; tx handler additionally requires delete:* for TxRemoveDoc - Client: createApiToken signature accepts optional scopes param - UI: scope preset dropdown in create popup (default: Read Only), permissions column in token list with i18n labels - Also fixes 3 pre-existing TS2322/TS2345 errors in operations.ts Signed-off-by: Don Kendall <kendall@donkendall.com> * test: add unit tests for API token scope enforcement - scopes.test.ts: 8 tests for hasScope() and getRequiredScope() logic - apiTokenScopes.test.ts: 7 tests for createApiToken scope validation (valid scopes, multiple scopes, no scopes backward compat, invalid format rejection, empty array rejection, domain-scope rejection) and listApiTokens scopes inclusion - Export hasScope/getRequiredScope from rpc.ts for testability Signed-off-by: Don Kendall <kendall@donkendall.com> * fix: address review feedback — role restriction, locale parity, formatting - Restrict API token creation/revocation to AccountRole.User or higher (guests cannot use API tokens), per reviewer suggestion - Add 5 missing translation keys (ApiTokenPermissions, ApiTokenScopePreset, ApiTokenScopeReadOnly, ApiTokenScopeReadWrite, ApiTokenScopeFullAccess) to all non-en locale files to fix locale parity CI test - Fix prettier formatting in apiTokenScopes.test.ts - Rename local `extra` to `tokenExtra` in createApiToken to avoid shadowing the decoded token's `extra` field Signed-off-by: Don Kendall <kendall@donkendall.com> * fix: address aonnikov architectural review feedback - rpc.ts: use system service token for checkApiTokenRevoked so the revocation check is not coupled to the user's potentially-revoked bearer token; systemAccountUuid + service:'server' ensures account service always accepts the call - ApiDocsSection.svelte: derive transactor base URL from login.metadata.LoginEndpoint (set on auth) instead of window.location.origin, which is not necessarily the transactor host - ApiTokenCreatePopup.svelte: replace manual translate() calls and themeStore language watch with DropdownLabelsIntl + DropdownIntlItem[], which handle i18n automatically; error state is now IntlString - General.svelte: remove legacy GenerateApiToken button, handler, and ApiTokenPopup import in favour of the new ApiTokens settings panel Signed-off-by: Don Kendall <kendall@donkendall.com> * fix: formatting in ApiTokenPopup, apiTokenScopes test, and operations Signed-off-by: Don Kendall <kendall@donkendall.com> * fix: apply rushx fmt to pass CI formatting check Signed-off-by: Don Kendall <dkendall@ledoweb.com> * fix: restore (s as any) cast in server_http.ts removed by ESLint autofix Signed-off-by: Don Kendall <dkendall@ledoweb.com> * refactor(token): centralize API token revocation/expiry in verifyToken Address @aonnikov's review: the bespoke checkApiTokenRevoked RPC and the ad-hoc revocation cache in the transactor are replaced by a reusable verifyToken in server-token that checks signature, expiry, and (for revokable API tokens) revocation via a pluggable checker. - server-token: add verifyToken + isTokenExpired + setApiTokenRevocationChecker (the 'method to verify' metadata the plugin needs, without depending on the account client). Revocation cache (60s TTL) now lives here, reusable by any service (transactor, blob access, etc.). - account: the account is now authoritative — wrap() rejects revoked/expired API tokens, so any account method (selectWorkspace/getWorkspaceInfo/...) naturally 401s. Removed the redundant checkApiTokenRevoked method. - account-client: drop checkApiTokenRevoked. - transactor: withSession uses verifyToken; the registered checker reuses the existing getLoginInfoByToken instead of a dedicated boolean RPC. Scopes are parsed once and threaded through (no re-decode in the tx handler). - tests: verifyToken/isTokenExpired unit coverage. Signed-off-by: Don Kendall <dkendall@ledoweb.com> * fix(setting): derive REST API base from account-provided transactor endpoint Address @aonnikov: the REST API host must come from the transactor endpoint returned by the account service (the login endpoint, a ws(s):// URL), not be constructed from window.location. Convert ws->http and append /api/v1, matching the existing ServerManagerGeneral pattern. Signed-off-by: Don Kendall <dkendall@ledoweb.com> * i18n(setting): translate API token strings across all locales Address @ArtyomSavchenko: the new API token UI strings were left in English in non-en locales. Provide translations for ru/de/es/fr/it/pt/pt-br/zh/ja/cs/tr. The en/ru locale-parity test passes (the prior failure was an en/ru key mismatch, resolved by the develop merge). Signed-off-by: Don Kendall <dkendall@ledoweb.com> * style: wrap long lines to satisfy prettier (account utils import, ApiDocsSection) Signed-off-by: Don Kendall <dkendall@ledoweb.com> * fix(api-token): drop unenforceable scopes, harden the token lifecycle The scopes were only ever consulted in the transactor's REST handler, so they promised a boundary the platform did not keep: - the WebSocket transport decodes the token and never looks at scopes, so a read-only token could open a socket and issue any transaction; - even on the REST path, delete:* only matched a top-level TxRemoveDoc and was bypassed by nesting the removal in a TxApplyIf; - nothing stopped a scoped token from calling createApiToken and minting an unscoped one. Narrowing a token's rights has to happen in the pipeline, where it covers every transport, and that is a larger design than this feature. Until then a token carries its account's rights and says so, rather than displaying a restriction that does not hold. Scopes are removed end to end. What is kept is made to work: - API tokens can no longer create, list or revoke API tokens. Otherwise a leaked token renews itself and revokes the tokens meant to stop it. - Revocation fails closed. The account is the only authority on it, so an unreachable account now rejects the token instead of admitting a revoked one to whoever can keep the account busy. Verdicts stay trusted for the cache TTL so brief outages do not cut off healthy tokens. - The revocation cache is bounded and re-checks negative verdicts. - Revoking your own token no longer requires a role in its workspace, so leaving a workspace cannot strand a credential you can never revoke. - The per-account limit counts only usable tokens; revoked and expired ones are kept for the audit trail and used to lock out anyone rotating. - Rejected REST tokens are logged with a reason, since expired, revoked and unverifiable are one opaque 401 from outside. The unused listWorkspaceApiTokens/revokeWorkspaceApiToken pair is removed; it had no caller and no test. Tests cover the role restriction, the API-token guard, ownership on revoke, the limit accounting and fail-closed revocation. Removing any of those checks fails them. Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7 Signed-off-by: Don Kendall <dkendall@ledoweb.com> * fix(setting): correct the API token settings page - Moves the page from workspace settings to account settings. Tokens belong to the account: the page already lists them across every workspace, and creating one only needs the User role the account service checks, not Owner as the category required. - Surfaces failures. A failed revoke was logged to the console and the row re-rendered unchanged; loading workspaces could reject unhandled and leave an empty dropdown with no explanation. - Outside a secure context there is no clipboard API, so the OK button did nothing and the dialog had no way out but Cancel. It now closes, leaving the token selectable. - Replaces hardcoded English labels, adds aria-expanded on the docs disclosure and labels on the copy targets. - Fills in the Korean and Polish translations, which were the only two locales missing these keys, and drops the API access strings orphaned when this PR replaced the old Generate API token button. Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7 Signed-off-by: Don Kendall <dkendall@ledoweb.com> * chore: drop docs/openapi.yaml from the API token PR Unrelated to this feature and wrong for this repo: it documents a /_tokens minting service and a tools/mint-token CLI that do not exist here, uses reverse-proxy prefixes from a self-hosted deployment rather than the transactor's /api/v1 routes, and states that revocation is a future enhancement, which is what this PR implements. Nothing references it. REST API docs belong in their own change, generated against the routes that exist. Claude-Session: https://claude.ai/code/session_01ANdoXbdn5k2hZy734EwKe7 Signed-off-by: Don Kendall <dkendall@ledoweb.com> --------- Signed-off-by: Don Kendall <kendall@donkendall.com> Signed-off-by: Don Kendall <dkendall@ledoweb.com>
This commit is contained in:
@@ -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<AccountUuid[]>
|
||||
|
||||
verify2fa: (code: string) => Promise<LoginInfo>
|
||||
createApiToken: (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number) => Promise<ApiTokenResult>
|
||||
listApiTokens: () => Promise<ApiTokenInfo[]>
|
||||
revokeApiToken: (tokenId: string) => Promise<void>
|
||||
|
||||
setCookie: () => Promise<void>
|
||||
deleteCookie: () => Promise<void>
|
||||
@@ -1233,6 +1238,33 @@ class AccountClientImpl implements AccountClient {
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async createApiToken (name: string, workspaceUuid: WorkspaceUuid, expiryDays: number): Promise<ApiTokenResult> {
|
||||
const request = {
|
||||
method: 'createApiToken' as const,
|
||||
params: { name, workspaceUuid, expiryDays }
|
||||
}
|
||||
|
||||
return await this.rpc(request)
|
||||
}
|
||||
|
||||
async listApiTokens (): Promise<ApiTokenInfo[]> {
|
||||
const request = {
|
||||
method: 'listApiTokens' as const,
|
||||
params: {}
|
||||
}
|
||||
|
||||
return await this.rpc(request)
|
||||
}
|
||||
|
||||
async revokeApiToken (tokenId: string): Promise<void> {
|
||||
const request = {
|
||||
method: 'revokeApiToken' as const,
|
||||
params: { tokenId }
|
||||
}
|
||||
|
||||
await this.rpc(request)
|
||||
}
|
||||
|
||||
async setCookie (): Promise<void> {
|
||||
const url = concatLink(this.url, '/cookie')
|
||||
const response = await fetch(url, { ...this.request, method: 'PUT' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean>
|
||||
|
||||
let apiTokenRevocationChecker: ApiTokenRevocationChecker | undefined
|
||||
|
||||
const REVOCATION_CACHE_TTL_MS = 60_000
|
||||
const REVOCATION_CACHE_LIMIT = 4096
|
||||
const revocationCache = new Map<string, { revoked: boolean, checkedAt: number }>()
|
||||
|
||||
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<boolean> {
|
||||
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<Token> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -98,4 +98,7 @@
|
||||
<path d="M12.0789 2.25C7.2854 2.25 3.34478 5.913 2.96055 10.5833H2.00002C1.69614 10.5833 1.42229 10.7667 1.30655 11.0477C1.19081 11.3287 1.25606 11.6517 1.47178 11.8657L3.15159 13.5324C3.444 13.8225 3.91567 13.8225 4.20808 13.5324L5.88789 11.8657C6.10361 11.6517 6.16886 11.3287 6.05312 11.0477C5.93738 10.7667 5.66353 10.5833 5.35965 10.5833H4.4668C4.84652 6.75167 8.10479 3.75 12.0789 3.75C14.8484 3.75 17.2727 5.20845 18.6156 7.39279C18.8325 7.74565 19.2944 7.85585 19.6473 7.63892C20.0002 7.42199 20.1104 6.96007 19.8934 6.60721C18.2871 3.99427 15.3873 2.25 12.0789 2.25Z"/>
|
||||
<path d="M20.8412 10.4666C20.5491 10.1778 20.0789 10.1778 19.7868 10.4666L18.1005 12.1333C17.8842 12.3471 17.8185 12.6703 17.934 12.9517C18.0496 13.233 18.3236 13.4167 18.6278 13.4167H19.5269C19.1456 17.2462 15.876 20.25 11.8828 20.25C9.10034 20.25 6.66595 18.7903 5.31804 16.6061C5.10051 16.2536 4.63841 16.1442 4.28591 16.3618C3.93342 16.5793 3.82401 17.0414 4.04154 17.3939C5.65416 20.007 8.56414 21.75 11.8828 21.75C16.6907 21.75 20.6476 18.0892 21.0332 13.4167H22.0002C22.3044 13.4167 22.5784 13.233 22.694 12.9517C22.8096 12.6703 22.7438 12.3471 22.5275 12.1333L20.8412 10.4666Z"/>
|
||||
</symbol>
|
||||
<symbol id="apiToken" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8.5 1a3.5 3.5 0 0 0-2.83 5.56L2 10.22V14h3v-2h2v-2h1.28l.39-.39A3.5 3.5 0 1 0 8.5 1Zm1.25 4a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "トークンの取り消しに失敗しました。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
|
||||
+284
-252
@@ -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": "토큰 취소에 실패했습니다. 다시 시도해 주세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Не удалось отозвать токен. Попробуйте ещё раз."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "撤销令牌失败,请重试。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<!--
|
||||
// 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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { fetchMetadataLocalStorage, Label } from '@hcengineering/ui'
|
||||
import { copyTextToClipboard } from '@hcengineering/presentation'
|
||||
import setting from '@hcengineering/setting'
|
||||
import login from '@hcengineering/login'
|
||||
import view from '@hcengineering/view'
|
||||
import { translate } from '@hcengineering/platform'
|
||||
import { themeStore } from '@hcengineering/theme'
|
||||
|
||||
let showApiDocs = false
|
||||
let copyHint = ''
|
||||
$: void translate(view.string.CopyToClipboard, {}, $themeStore.language).then((t) => {
|
||||
copyHint = t
|
||||
})
|
||||
|
||||
// The REST API is served by the transactor. Its address is returned by the
|
||||
// account service on authentication and stored as the login endpoint (a
|
||||
// ws(s):// URL); derive the http(s) base from it the same way other tools do.
|
||||
const transactorEndpoint = (fetchMetadataLocalStorage(login.metadata.LoginEndpoint) ?? '').replace(/^ws/, 'http')
|
||||
const baseApiUrl =
|
||||
(transactorEndpoint.endsWith('/') ? transactorEndpoint.slice(0, -1) : transactorEndpoint) + '/api/v1'
|
||||
$: curlExample = `curl -H "Authorization: Bearer YOUR_TOKEN" \\\n "${baseApiUrl}/find-all/WORKSPACE_ID?class=tracker:class:Project"`
|
||||
|
||||
async function copySnippet (text: string): Promise<void> {
|
||||
await copyTextToClipboard(text)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="api-docs-section">
|
||||
<button
|
||||
class="api-docs-toggle"
|
||||
aria-expanded={showApiDocs}
|
||||
on:click={() => {
|
||||
showApiDocs = !showApiDocs
|
||||
}}
|
||||
>
|
||||
<span class="api-docs-arrow" class:expanded={showApiDocs}>▶</span>
|
||||
<Label label={setting.string.ApiUsageTitle} />
|
||||
</button>
|
||||
{#if showApiDocs}
|
||||
<div class="api-docs-content">
|
||||
<p class="api-docs-desc"><Label label={setting.string.ApiUsageDescription} /></p>
|
||||
|
||||
<div class="api-docs-block">
|
||||
<span class="api-docs-label"><Label label={setting.string.ApiBaseUrl} /></span>
|
||||
<code
|
||||
class="api-docs-code clickable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={copyHint}
|
||||
on:click={() => copySnippet(baseApiUrl)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') copySnippet(baseApiUrl)
|
||||
}}>{baseApiUrl}</code
|
||||
>
|
||||
</div>
|
||||
|
||||
<p class="api-docs-note"><Label label={setting.string.ApiWorkspaceId} /></p>
|
||||
|
||||
<div class="api-docs-endpoints">
|
||||
<div class="api-docs-endpoint">
|
||||
<div class="api-docs-method get">GET</div>
|
||||
<code>/api/v1/ping/:workspaceId</code>
|
||||
<span class="api-docs-endpoint-desc"><Label label={setting.string.ApiEndpointPing} /></span>
|
||||
</div>
|
||||
<div class="api-docs-endpoint">
|
||||
<div class="api-docs-method get">GET</div>
|
||||
<code>/api/v1/find-all/:workspaceId?class=...</code>
|
||||
<span class="api-docs-endpoint-desc"><Label label={setting.string.ApiEndpointFindAll} /></span>
|
||||
</div>
|
||||
<div class="api-docs-endpoint">
|
||||
<div class="api-docs-method post">POST</div>
|
||||
<code>/api/v1/find-all/:workspaceId</code>
|
||||
<span class="api-docs-endpoint-desc"><Label label={setting.string.ApiEndpointFindAllPost} /></span>
|
||||
</div>
|
||||
<div class="api-docs-endpoint">
|
||||
<div class="api-docs-method post">POST</div>
|
||||
<code>/api/v1/tx/:workspaceId</code>
|
||||
<span class="api-docs-endpoint-desc"><Label label={setting.string.ApiEndpointTx} /></span>
|
||||
</div>
|
||||
<div class="api-docs-endpoint">
|
||||
<div class="api-docs-method get">GET</div>
|
||||
<code>/api/v1/load-model/:workspaceId</code>
|
||||
<span class="api-docs-endpoint-desc"><Label label={setting.string.ApiEndpointLoadModel} /></span>
|
||||
</div>
|
||||
<div class="api-docs-endpoint">
|
||||
<div class="api-docs-method get">GET</div>
|
||||
<code>/api/v1/account/:workspaceId</code>
|
||||
<span class="api-docs-endpoint-desc"><Label label={setting.string.ApiEndpointAccount} /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-docs-example">
|
||||
<span class="api-docs-label"><Label label={view.string.CopyToClipboard} /></span>
|
||||
<pre
|
||||
class="api-docs-pre clickable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={copyHint}
|
||||
on:click={() => copySnippet(curlExample)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') copySnippet(curlExample)
|
||||
}}>{curlExample}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.api-docs-section {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--theme-popup-divider);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.api-docs-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-content-color);
|
||||
user-select: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.api-docs-arrow {
|
||||
display: inline-block;
|
||||
font-size: 0.625rem;
|
||||
transition: transform 0.15s ease;
|
||||
color: var(--theme-dark-color);
|
||||
}
|
||||
.api-docs-arrow.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.api-docs-content {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.api-docs-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-dark-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.api-docs-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-dark-color);
|
||||
font-style: italic;
|
||||
}
|
||||
.api-docs-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.api-docs-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-dark-color);
|
||||
}
|
||||
.api-docs-code {
|
||||
background: var(--theme-popup-color);
|
||||
border: 1px solid var(--theme-popup-divider);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-content-color);
|
||||
width: fit-content;
|
||||
}
|
||||
.api-docs-endpoints {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.api-docs-endpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
|
||||
code {
|
||||
font-family: var(--mono-font);
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
}
|
||||
.api-docs-endpoint-desc {
|
||||
color: var(--theme-dark-color);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.api-docs-method {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--mono-font);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.api-docs-method.get {
|
||||
background-color: var(--tag-accent-PorpoiseColor);
|
||||
color: var(--tag-on-accent-PorpoiseColor);
|
||||
}
|
||||
.api-docs-method.post {
|
||||
background-color: var(--tag-accent-SunshineColor);
|
||||
color: var(--tag-on-accent-SunshineColor);
|
||||
}
|
||||
.api-docs-example {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.api-docs-pre {
|
||||
background: var(--theme-popup-color);
|
||||
border: 1px solid var(--theme-popup-divider);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-content-color);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: var(--theme-button-hovered);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<!--
|
||||
// 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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type Timestamp, type WorkspaceUuid } from '@hcengineering/core'
|
||||
import presentation, { copyTextToClipboard } from '@hcengineering/presentation'
|
||||
import { type IntlString } from '@hcengineering/platform'
|
||||
import view from '@hcengineering/view'
|
||||
import {
|
||||
DropdownLabelsIntl,
|
||||
type DropdownIntlItem,
|
||||
Label,
|
||||
ListItem,
|
||||
Modal,
|
||||
ModernEditbox,
|
||||
Dropdown,
|
||||
ticker
|
||||
} from '@hcengineering/ui'
|
||||
import setting from '@hcengineering/setting'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { getAccountClient } from '../utils'
|
||||
|
||||
let name = ''
|
||||
let loading = false
|
||||
let error: IntlString | undefined
|
||||
let wsItems: ListItem[] = []
|
||||
let selectedWs: ListItem | undefined
|
||||
let createdToken: string | undefined
|
||||
let copiedTime: Timestamp | undefined
|
||||
let copied = false
|
||||
|
||||
const expiryItems: DropdownIntlItem[] = [
|
||||
{ id: '7', label: setting.string.ApiTokenExpiry7Days },
|
||||
{ id: '30', label: setting.string.ApiTokenExpiry30Days },
|
||||
{ id: '90', label: setting.string.ApiTokenExpiry90Days },
|
||||
{ id: '180', label: setting.string.ApiTokenExpiry180Days },
|
||||
{ id: '365', label: setting.string.ApiTokenExpiry365Days }
|
||||
]
|
||||
let selectedExpiry: string = '30'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: canSave = !loading && name.trim().length > 0 && selectedWs !== undefined && createdToken === undefined
|
||||
|
||||
$: if (copiedTime !== undefined && copied && $ticker - copiedTime > 1500) {
|
||||
copied = false
|
||||
}
|
||||
|
||||
async function loadWorkspaces (): Promise<void> {
|
||||
try {
|
||||
const workspaces = await getAccountClient().getUserWorkspaces()
|
||||
wsItems = workspaces.map((w) => ({ _id: w.uuid, label: w.name ?? w.url }))
|
||||
if (wsItems.length > 0) {
|
||||
selectedWs = wsItems[0]
|
||||
}
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
error = setting.string.ApiTokenLoadError
|
||||
}
|
||||
}
|
||||
|
||||
async function create (): Promise<void> {
|
||||
if (selectedWs === undefined) return
|
||||
loading = true
|
||||
error = undefined
|
||||
try {
|
||||
const result = await getAccountClient().createApiToken(
|
||||
name.trim(),
|
||||
selectedWs._id as WorkspaceUuid,
|
||||
parseInt(selectedExpiry, 10)
|
||||
)
|
||||
createdToken = result.token
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
error = setting.string.ApiTokenCreateError
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken (): Promise<void> {
|
||||
if (createdToken === undefined) return
|
||||
if (!window.isSecureContext) {
|
||||
// No clipboard API outside a secure context. The token stays selectable above.
|
||||
dispatch('close', true)
|
||||
return
|
||||
}
|
||||
await copyTextToClipboard(createdToken)
|
||||
copied = true
|
||||
copiedTime = Date.now()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadWorkspaces()
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
type="type-popup"
|
||||
label={createdToken !== undefined ? setting.string.ApiTokenCreated : setting.string.CreateApiToken}
|
||||
{canSave}
|
||||
okLabel={createdToken !== undefined
|
||||
? copied
|
||||
? view.string.Copied
|
||||
: view.string.CopyToClipboard
|
||||
: presentation.string.Create}
|
||||
okAction={createdToken !== undefined ? copyToken : create}
|
||||
onCancel={() => {
|
||||
dispatch('close', createdToken !== undefined)
|
||||
}}
|
||||
>
|
||||
{#if createdToken !== undefined}
|
||||
<div class="antiPopup-msg token-reveal">
|
||||
<span class="label"><Label label={setting.string.ApiTokenCopyWarning} /></span>
|
||||
<div
|
||||
class="token-value"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={copyToken}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') copyToken()
|
||||
}}
|
||||
>
|
||||
{createdToken}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="antiPopup-msg">
|
||||
<ModernEditbox label={setting.string.ApiTokenName} bind:value={name} size="large" kind="ghost" autoFocus />
|
||||
</div>
|
||||
<div class="antiPopup-msg">
|
||||
<span class="label"><Label label={setting.string.ApiTokenWorkspace} /></span>
|
||||
<Dropdown placeholder={setting.string.ApiTokenWorkspace} items={wsItems} bind:selected={selectedWs} />
|
||||
</div>
|
||||
<div class="antiPopup-msg">
|
||||
<span class="label"><Label label={setting.string.ApiTokenExpiry} /></span>
|
||||
<DropdownLabelsIntl
|
||||
kind="regular"
|
||||
size="medium"
|
||||
items={expiryItems}
|
||||
selected={selectedExpiry}
|
||||
on:selected={(e) => {
|
||||
selectedExpiry = e.detail
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if error !== undefined}
|
||||
<div class="antiPopup-msg error"><Label label={error} /></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.token-reveal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.token-value {
|
||||
background: var(--theme-popup-color);
|
||||
border: 1px solid var(--theme-popup-divider);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
user-select: all;
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-dark-color);
|
||||
}
|
||||
.error {
|
||||
color: var(--theme-error-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,105 +0,0 @@
|
||||
<!--
|
||||
// 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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Timestamp } from '@hcengineering/core'
|
||||
import presentation, { copyTextToClipboard } from '@hcengineering/presentation'
|
||||
import view from '@hcengineering/view'
|
||||
import { Button, Label, ticker } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import settings from '../plugin'
|
||||
|
||||
export let token: string
|
||||
|
||||
const isSecureContext = window.isSecureContext
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let copiedTime: Timestamp | undefined
|
||||
let copied = false
|
||||
|
||||
$: if (copiedTime !== undefined && copied && $ticker - copiedTime > 1000) {
|
||||
copied = false
|
||||
}
|
||||
|
||||
async function copy (): Promise<void> {
|
||||
if (!isSecureContext) return
|
||||
if (token === undefined) return
|
||||
|
||||
await copyTextToClipboard(token)
|
||||
copied = true
|
||||
copiedTime = Date.now()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="antiPopup popup" class:secure={isSecureContext}>
|
||||
<div class="overflow-label fs-title mb-4">
|
||||
<Label label={settings.string.ApiToken} />
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="token" class:notSecure={!isSecureContext} class:over-underline={isSecureContext} on:click={copy}>
|
||||
{token}
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<Button
|
||||
label={presentation.string.Close}
|
||||
size={'medium'}
|
||||
kind={'primary'}
|
||||
on:click={() => {
|
||||
dispatch('close')
|
||||
}}
|
||||
/>
|
||||
{#if isSecureContext}
|
||||
<Button label={copied ? view.string.Copied : view.string.CopyToClipboard} size={'medium'} on:click={copy} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.75rem;
|
||||
width: 30rem;
|
||||
max-width: 40rem;
|
||||
background: var(--popup-bg-color);
|
||||
border-radius: 1.25rem;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
box-shadow: var(--popup-shadow);
|
||||
|
||||
.token {
|
||||
margin: 1.75rem 0 0;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
&.notSecure {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
direction: rtl;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<!--
|
||||
// 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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Breadcrumb, Header, IconAdd, Label, Loading, ModernButton, Scroller, showPopup } from '@hcengineering/ui'
|
||||
import { MessageBox } from '@hcengineering/presentation'
|
||||
import setting from '@hcengineering/setting'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { type ApiTokenInfo } from '@hcengineering/account-client'
|
||||
import { getAccountClient } from '../utils'
|
||||
import { onMount } from 'svelte'
|
||||
import { themeStore } from '@hcengineering/theme'
|
||||
import ApiTokenCreatePopup from './ApiTokenCreatePopup.svelte'
|
||||
import ApiDocsSection from './ApiDocsSection.svelte'
|
||||
|
||||
let loading = true
|
||||
let loadError = false
|
||||
let revokeError = false
|
||||
let tokens: ApiTokenInfo[] = []
|
||||
|
||||
const statusLabelMap = {
|
||||
active: setting.string.ApiTokenStatusActive,
|
||||
expiring: setting.string.ApiTokenStatusExpiring,
|
||||
revoked: setting.string.ApiTokenStatusRevoked,
|
||||
expired: setting.string.ApiTokenStatusExpired
|
||||
} as const
|
||||
|
||||
function loadTokens (): void {
|
||||
loading = true
|
||||
loadError = false
|
||||
getAccountClient()
|
||||
.listApiTokens()
|
||||
.then((res) => {
|
||||
tokens = res.sort((a, b) => b.createdOn - a.createdOn)
|
||||
loading = false
|
||||
})
|
||||
.catch((err) => {
|
||||
Analytics.handleError(err)
|
||||
tokens = []
|
||||
loading = false
|
||||
loadError = true
|
||||
})
|
||||
}
|
||||
|
||||
function create (): void {
|
||||
showPopup(ApiTokenCreatePopup, {}, 'top', (res) => {
|
||||
if (res === true) {
|
||||
loadTokens()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function revoke (token: ApiTokenInfo): void {
|
||||
showPopup(MessageBox, {
|
||||
label: setting.string.ApiTokenRevoke,
|
||||
message: setting.string.ApiTokenRevokeConfirm,
|
||||
dangerous: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await getAccountClient().revokeApiToken(token.id)
|
||||
revokeError = false
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
revokeError = true
|
||||
}
|
||||
loadTokens()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate (ts: number): string {
|
||||
return new Date(ts).toLocaleDateString($themeStore.language ?? 'en', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function getStatus (token: ApiTokenInfo): 'active' | 'expiring' | 'revoked' | 'expired' {
|
||||
if (token.revoked) return 'revoked'
|
||||
const now = Date.now()
|
||||
if (token.expiresOn < now) return 'expired'
|
||||
if (token.expiresOn - now < 7 * 86400000) return 'expiring'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadTokens()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="hulyComponent">
|
||||
<Header adaptive={'disabled'}>
|
||||
<Breadcrumb icon={setting.icon.ApiToken} label={setting.string.ApiTokens} size="large" isCurrent />
|
||||
<svelte:fragment slot="actions">
|
||||
<ModernButton
|
||||
kind="primary"
|
||||
icon={IconAdd}
|
||||
label={setting.string.CreateApiToken}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
on:click={create}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</Header>
|
||||
<div class="hulyComponent-content__container columns">
|
||||
<div class="hulyComponent-content__column p-6">
|
||||
{#if loading}
|
||||
<Loading />
|
||||
{:else if loadError}
|
||||
<div class="hulyComponent-content__empty">
|
||||
<Label label={setting.string.ApiTokenLoadError} />
|
||||
<div class="mt-2">
|
||||
<ModernButton label={setting.string.Reconnect} size="small" on:click={loadTokens} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if revokeError}
|
||||
<div class="hulyComponent-content__empty">
|
||||
<Label label={setting.string.ApiTokenRevokeError} />
|
||||
<div class="mt-2">
|
||||
<ModernButton label={setting.string.Reconnect} size="small" on:click={loadTokens} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if tokens.length === 0}
|
||||
<div class="hulyComponent-content__empty">
|
||||
<Label label={setting.string.ApiTokenNoTokens} />
|
||||
</div>
|
||||
{:else}
|
||||
<Scroller>
|
||||
<table class="antiGrid">
|
||||
<thead class="scroller-thead">
|
||||
<tr class="scroller-thead__tr">
|
||||
<th><Label label={setting.string.ApiTokenName} /></th>
|
||||
<th><Label label={setting.string.ApiTokenWorkspace} /></th>
|
||||
<th><Label label={setting.string.Created} /></th>
|
||||
<th><Label label={setting.string.Expires} /></th>
|
||||
<th><Label label={setting.string.TokenStatus} /></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tokens as token}
|
||||
{@const status = getStatus(token)}
|
||||
<tr class="antiGrid-row">
|
||||
<td class="overflow-label font-medium-14">{token.name}</td>
|
||||
<td class="overflow-label">{token.workspaceName}</td>
|
||||
<td>{formatDate(token.createdOn)}</td>
|
||||
<td>{token.revoked ? '—' : formatDate(token.expiresOn)}</td>
|
||||
<td>
|
||||
<span
|
||||
class="tag-item"
|
||||
class:tag-active={status === 'active'}
|
||||
class:tag-warning={status === 'expiring'}
|
||||
class:tag-negative={status === 'revoked' || status === 'expired'}
|
||||
>
|
||||
<Label label={statusLabelMap[status]} />
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if !token.revoked}
|
||||
<ModernButton
|
||||
kind="negative"
|
||||
label={setting.string.ApiTokenRevoke}
|
||||
size="small"
|
||||
on:click={() => {
|
||||
revoke(token)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Scroller>
|
||||
{/if}
|
||||
|
||||
<ApiDocsSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag-active {
|
||||
background-color: var(--tag-accent-PorpoiseColor);
|
||||
color: var(--tag-on-accent-PorpoiseColor);
|
||||
}
|
||||
.tag-warning {
|
||||
background-color: var(--tag-accent-SunshineColor);
|
||||
color: var(--tag-on-accent-SunshineColor);
|
||||
}
|
||||
.tag-negative {
|
||||
background-color: var(--tag-accent-FlamingoColor);
|
||||
color: var(--tag-on-accent-FlamingoColor);
|
||||
}
|
||||
.tag-scope {
|
||||
background-color: var(--theme-button-default);
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
</style>
|
||||
@@ -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<void> {
|
||||
const { token } = await accountClient.selectWorkspace(workspaceUrl)
|
||||
showPopup(ApiTokenPopup, { token })
|
||||
}
|
||||
|
||||
function handleTogglePermissions (): void {
|
||||
const newState = !arePermissionsDisabled
|
||||
showPopup(MessageBox, {
|
||||
@@ -318,19 +312,6 @@
|
||||
allowGuests={true}
|
||||
/>
|
||||
|
||||
<div class="flex-col flex-gap-4 mt-6">
|
||||
<div class="title"><Label label={settingsRes.string.ApiAccess} /></div>
|
||||
<div class="w-32">
|
||||
<Button
|
||||
label={settingsRes.string.GenerateApiToken}
|
||||
kind="regular"
|
||||
disabled={workspaceUrl === ''}
|
||||
showTooltip={{ label: settingsRes.string.GenerateApiToken }}
|
||||
on:click={handleGenerateApiToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-col flex-gap-4 mt-6">
|
||||
<div class="title"><Label label={settingsRes.string.DangerZone} /></div>
|
||||
<div class="w-32">
|
||||
|
||||
@@ -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<Resources> => ({
|
||||
AddEmailSocialId,
|
||||
EmployeeRefEditor,
|
||||
UserRoleSelect,
|
||||
TwoFactorSettings
|
||||
TwoFactorSettings,
|
||||
ApiTokens
|
||||
},
|
||||
actionImpl: {
|
||||
DeleteMixin
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -200,7 +200,8 @@ export default plugin(settingId, {
|
||||
OfficeSettings: '' as Ref<Doc>,
|
||||
DisablePermissionsConfiguration: '' as Ref<Configuration>,
|
||||
Mailboxes: '' as Ref<Doc>,
|
||||
Security: '' as Ref<Doc>
|
||||
Security: '' as Ref<Doc>,
|
||||
ApiTokens: '' as Ref<Doc>
|
||||
},
|
||||
mixin: {
|
||||
Editable: '' as Ref<Mixin<Editable>>,
|
||||
@@ -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<TemplateFieldCategory>
|
||||
|
||||
+29
-3
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string, any> = {}): 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceMember>
|
||||
workspacePermission: MongoDbCollection<WorkspacePermission>
|
||||
apiToken: MongoDbCollection<ApiToken, 'id'>
|
||||
|
||||
constructor (readonly db: Db) {
|
||||
this.migration = new MongoDbCollection<MigrationInfo, 'key'>('migration', db, 'key')
|
||||
@@ -431,6 +433,7 @@ export class MongoAccountDB implements AccountDB {
|
||||
|
||||
this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db)
|
||||
this.workspacePermission = new MongoDbCollection<WorkspacePermission>('workspacePermissions', db)
|
||||
this.apiToken = new MongoDbCollection<ApiToken, 'id'>('apiTokens', db, 'id')
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
`
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
UserProfile,
|
||||
Subscription,
|
||||
WorkspacePermission,
|
||||
ApiToken,
|
||||
DBFlavor
|
||||
} from '../../types'
|
||||
|
||||
@@ -540,6 +541,7 @@ export class PostgresAccountDB implements AccountDB {
|
||||
userProfile: PostgresDbCollection<UserProfile, 'personUuid'>
|
||||
subscription: PostgresDbCollection<Subscription, 'id'>
|
||||
workspacePermission: PostgresDbCollection<WorkspacePermission>
|
||||
apiToken: PostgresDbCollection<ApiToken, 'id'>
|
||||
|
||||
constructor (
|
||||
readonly client: Sql,
|
||||
@@ -609,6 +611,12 @@ export class PostgresAccountDB implements AccountDB {
|
||||
timestampFields: ['createdOn'],
|
||||
withRetryClient
|
||||
})
|
||||
this.apiToken = new PostgresDbCollection<ApiToken, 'id'>('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)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
type Token
|
||||
} from '@hcengineering/server-token'
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import { isAdminEmail } from './admin'
|
||||
import { accountPlugin } from './plugin'
|
||||
import { type AccountServiceMethods, getServiceMethods } from './serviceOperations'
|
||||
@@ -2675,6 +2676,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<string, any> | 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<void> {
|
||||
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,
|
||||
@@ -3454,6 +3618,9 @@ export type AccountMethods =
|
||||
| 'hasWorkspacePermission'
|
||||
| 'getWorkspacePermissions'
|
||||
| 'getWorkspaceUsersWithPermission'
|
||||
| 'createApiToken'
|
||||
| 'listApiTokens'
|
||||
| 'revokeApiToken'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -3521,6 +3688,11 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
|
||||
getWorkspacePermissions: wrap(getWorkspacePermissions),
|
||||
getWorkspaceUsersWithPermission: wrap(getWorkspaceUsersWithPermission),
|
||||
|
||||
/* API TOKENS */
|
||||
createApiToken: wrap(createApiToken),
|
||||
listApiTokens: wrap(listApiTokens),
|
||||
revokeApiToken: wrap(revokeApiToken),
|
||||
|
||||
/* READ OPERATIONS */
|
||||
getRegionInfo: wrap(getRegionInfo),
|
||||
getUserWorkspaces: wrap(getUserWorkspaces),
|
||||
|
||||
@@ -160,6 +160,28 @@ export interface WorkspaceJoinInfo {
|
||||
invite?: WorkspaceInvite | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an API token record in the database.
|
||||
* Timestamps are in milliseconds since Unix epoch.
|
||||
*
|
||||
* A token carries the full rights of the account that created it. Narrowing
|
||||
* that down needs enforcement in the pipeline, where it applies to every
|
||||
* transport, so it is deliberately not attempted here.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ApiToken {
|
||||
id: string
|
||||
accountUuid: PersonUuid
|
||||
name: string
|
||||
workspaceUuid: WorkspaceUuid
|
||||
/** Milliseconds since epoch */
|
||||
createdOn: number
|
||||
/** Milliseconds since epoch */
|
||||
expiresOn: number
|
||||
revoked: boolean
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
accountUuid: PersonUuid
|
||||
mailbox: string
|
||||
@@ -327,6 +349,7 @@ export interface AccountDB {
|
||||
userProfile: DbCollection<UserProfile>
|
||||
subscription: DbCollection<Subscription>
|
||||
workspacePermission: DbCollection<WorkspacePermission>
|
||||
apiToken: DbCollection<ApiToken>
|
||||
|
||||
init: () => Promise<void>
|
||||
createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid>
|
||||
|
||||
@@ -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<any> {
|
||||
// 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) => {
|
||||
|
||||
Reference in New Issue
Block a user