diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index b6c00973..cd19899c 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -10,9 +10,6 @@ const endpoints = { MODELS: '/api/models', DOCS: '/api/sources', DOCS_PAGINATED: '/api/sources/paginated', - API_KEYS: '/api/get_api_keys', - CREATE_API_KEY: '/api/create_api_key', - DELETE_API_KEY: '/api/delete_api_key', AGENT: (id: string) => `/api/get_agent?id=${id}`, AGENTS: '/api/get_agents', GUARDRAIL_CATALOG: '/api/guardrails/catalog', @@ -155,6 +152,8 @@ const endpoints = { DEVICE_PAIRINGS: '/api/devices/pairings', DEVICE_PAIRING: (deviceCode: string) => `/api/devices/pairings/${deviceCode}`, + ACCESS_TOKENS: '/api/user/tokens', + ACCESS_TOKEN: (id: string) => `/api/user/tokens/${id}`, }, V1: { CHAT_COMPLETIONS: '/v1/chat/completions', diff --git a/frontend/src/api/services/patService.test.ts b/frontend/src/api/services/patService.test.ts new file mode 100644 index 00000000..63b7a88e --- /dev/null +++ b/frontend/src/api/services/patService.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import apiClient from '../client'; +import patService, { AccessTokenApiError } from './patService'; + +afterEach(() => vi.restoreAllMocks()); + +const response = (body: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }) as unknown as Response; + +const TOKEN_ROW = { + id: '7f1c2c1e-0f59-4d0a-9d55-0f6a3d1d7a11', + name: 'ci', + token_prefix: 'dgpt_pat_ab12cd', + scopes: ['agents:read'], + resource_filter: {}, + status: 'active', + expires_at: null, + last_used_at: null, + last_used_ip: null, + created_at: '2026-09-01T10:00:00+00:00', + revoked_at: null, +}; + +describe('patService.list', () => { + it('GETs /api/user/tokens with the session token and returns the body', async () => { + const body = { + success: true, + tokens: [TOKEN_ROW], + scopes: [{ name: 'agents:read', description: 'View agents' }], + policy: { enabled: true }, + }; + const spy = vi.spyOn(apiClient, 'get').mockResolvedValue(response(body)); + + const result = await patService.list('session-jwt'); + + expect(spy).toHaveBeenCalledWith('/api/user/tokens', 'session-jwt'); + expect(result.tokens).toEqual([TOKEN_ROW]); + expect(result.scopes[0].name).toBe('agents:read'); + }); + + it('throws with the server message on a non-2xx response', async () => { + vi.spyOn(apiClient, 'get').mockResolvedValue( + response({ success: false, message: 'Authentication required' }, 401), + ); + + await expect(patService.list(null)).rejects.toMatchObject({ + name: 'AccessTokenApiError', + message: 'Authentication required', + status: 401, + }); + }); +}); + +describe('patService.create', () => { + it('POSTs the payload and returns the one-time plaintext token', async () => { + const spy = vi.spyOn(apiClient, 'post').mockResolvedValue( + response( + { + success: true, + token: 'dgpt_pat_secret', + personal_access_token: TOKEN_ROW, + }, + 201, + ), + ); + const payload = { + name: 'ci', + scopes: ['agents:read'], + resource_filter: { agents: [TOKEN_ROW.id] }, + expires_in_days: 30, + }; + + const result = await patService.create(payload, 'session-jwt'); + + expect(spy).toHaveBeenCalledWith( + '/api/user/tokens', + payload, + 'session-jwt', + ); + expect(result.token).toBe('dgpt_pat_secret'); + expect(result.personal_access_token.id).toBe(TOKEN_ROW.id); + }); + + it.each([ + [400, 'Unknown scopes: nope:read'], + [403, 'Personal access tokens are not available on this server'], + [409, 'A token with this name already exists'], + ])('surfaces the %i error message', async (status, message) => { + vi.spyOn(apiClient, 'post').mockResolvedValue( + response({ success: false, message }, status), + ); + + const error = await patService + .create({ name: 'ci', scopes: ['agents:read'] }, 'session-jwt') + .catch((e) => e); + + expect(error).toBeInstanceOf(AccessTokenApiError); + expect(error.message).toBe(message); + expect(error.status).toBe(status); + }); + + it('throws an empty message when the error body is not JSON', async () => { + vi.spyOn(apiClient, 'post').mockResolvedValue({ + ok: false, + status: 502, + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + } as unknown as Response); + + await expect( + patService.create({ name: 'ci', scopes: ['agents:read'] }, null), + ).rejects.toMatchObject({ message: '', status: 502 }); + }); + + it('treats success:false on a 2xx as a failure', async () => { + vi.spyOn(apiClient, 'post').mockResolvedValue( + response({ success: false, message: 'nope' }, 200), + ); + + await expect( + patService.create({ name: 'ci', scopes: ['agents:read'] }, null), + ).rejects.toThrow('nope'); + }); +}); + +describe('patService.revoke', () => { + it('DELETEs the token by id', async () => { + const spy = vi + .spyOn(apiClient, 'delete') + .mockResolvedValue(response({ success: true })); + + await expect( + patService.revoke(TOKEN_ROW.id, 'session-jwt'), + ).resolves.toEqual({ success: true }); + expect(spy).toHaveBeenCalledWith( + `/api/user/tokens/${TOKEN_ROW.id}`, + 'session-jwt', + ); + }); + + it('throws when the token is not found', async () => { + vi.spyOn(apiClient, 'delete').mockResolvedValue( + response({ success: false, message: 'Token not found' }, 404), + ); + + await expect(patService.revoke('missing', null)).rejects.toThrow( + 'Token not found', + ); + }); +}); diff --git a/frontend/src/api/services/patService.ts b/frontend/src/api/services/patService.ts new file mode 100644 index 00000000..09eb197a --- /dev/null +++ b/frontend/src/api/services/patService.ts @@ -0,0 +1,110 @@ +import apiClient from '../client'; +import endpoints from '../endpoints'; + +export interface PersonalAccessToken { + id: string; + name: string; + /** Display prefix only (e.g. `dgpt_pat_ab12cd`); never the full secret. */ + token_prefix: string; + scopes: string[]; + /** `{family: [resource ids]}`; a missing family means "all resources". */ + resource_filter: Record; + status: string; + expires_at: string | null; + last_used_at: string | null; + last_used_ip: string | null; + created_at: string | null; + revoked_at: string | null; +} + +export interface AccessTokenScope { + name: string; + description: string; +} + +export interface AccessTokenPolicy { + enabled: boolean; + default_lifetime_days: number; + max_lifetime_days: number; + allow_non_expiring: boolean; + max_per_user: number; + filterable_families: string[]; +} + +export interface AccessTokenListResponse { + tokens: PersonalAccessToken[]; + scopes: AccessTokenScope[]; + policy: AccessTokenPolicy; +} + +export interface CreateAccessTokenPayload { + name: string; + scopes: string[]; + resource_filter?: Record; + /** `null`/omitted = server default, `0` = never expires. */ + expires_in_days?: number | null; +} + +export interface CreateAccessTokenResponse { + /** Plaintext secret. The server returns it exactly once. */ + token: string; + personal_access_token: PersonalAccessToken; +} + +/** Error carrying the server's user-facing `message` and the HTTP status. */ +export class AccessTokenApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'AccessTokenApiError'; + this.status = status; + } +} + +// apiClient resolves to the raw fetch Response (the app convention). Parse it +// here and turn `{success:false, message}` / non-2xx into a thrown error so +// callers can show the server's message inline. +const parse = async (response: Response): Promise => { + let body: { success?: boolean; message?: unknown } | null = null; + try { + body = await response.json(); + } catch { + body = null; + } + if (!response.ok || body?.success === false) { + throw new AccessTokenApiError( + typeof body?.message === 'string' ? body.message : '', + response.status, + ); + } + return body as T; +}; + +const patService = { + list: async (token: string | null): Promise => + parse( + await apiClient.get(endpoints.USER.ACCESS_TOKENS, token), + ), + + create: async ( + payload: CreateAccessTokenPayload, + token: string | null, + ): Promise => + parse( + await apiClient.post(endpoints.USER.ACCESS_TOKENS, payload, token), + ), + + revoke: async ( + id: string, + token: string | null, + ): Promise<{ success: boolean }> => + parse<{ success: boolean }>( + await apiClient.delete( + endpoints.USER.ACCESS_TOKEN(encodeURIComponent(id)), + token, + ), + ), +}; + +export default patService; diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 585a1ef3..b9de3bc0 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -19,12 +19,6 @@ const userService = { apiClient.get(`${endpoints.USER.DOCS}`, token), getDocsWithPagination: (query: string, token: string | null): Promise => apiClient.get(`${endpoints.USER.DOCS_PAGINATED}?${query}`, token), - getAPIKeys: (token: string | null): Promise => - apiClient.get(endpoints.USER.API_KEYS, token), - createAPIKey: (data: any, token: string | null): Promise => - apiClient.post(endpoints.USER.CREATE_API_KEY, data, token), - deleteAPIKey: (data: any, token: string | null): Promise => - apiClient.post(endpoints.USER.DELETE_API_KEY, data, token), getAgent: (id: string, token: string | null): Promise => throttledApiClient.get(endpoints.USER.AGENT(id), token), getAgents: (token: string | null): Promise => diff --git a/frontend/src/locale/accessTokens.test.ts b/frontend/src/locale/accessTokens.test.ts new file mode 100644 index 00000000..5415f4ce --- /dev/null +++ b/frontend/src/locale/accessTokens.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import de from './de.json'; +import en from './en.json'; +import es from './es.json'; +import jp from './jp.json'; +import ru from './ru.json'; +import zhTW from './zh-TW.json'; +import zh from './zh.json'; + +type Tree = { [key: string]: string | Tree }; + +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; + +// Plural categories differ per language (ru adds few/many), so compare the +// keys with the suffix stripped. +const flatten = (tree: Tree, prefix = ''): string[] => + Object.entries(tree).flatMap(([key, value]) => + typeof value === 'string' + ? [prefix + key.replace(PLURAL_SUFFIX, '')] + : flatten(value, `${prefix}${key}.`), + ); + +const keysOf = (locale: { settings: object }): string[] => { + const block = (locale.settings as Tree).accessTokens as Tree; + return Array.from(new Set(flatten(block))).sort(); +}; + +const LOCALES = { es, de, jp, ru, zh, zhTW }; + +describe('settings.accessTokens locale block', () => { + it.each(Object.entries(LOCALES))( + '%s has the same keys as en', + (_name, locale) => { + expect(keysOf(locale)).toEqual(keysOf(en)); + }, + ); + + it.each(Object.entries(LOCALES))( + '%s is translated, not an English copy', + (_name, locale) => { + const block = (locale.settings as Tree).accessTokens as Tree; + const source = (en.settings as Tree).accessTokens as Tree; + expect(block.label).not.toBe(source.label); + expect((block.created as Tree).warning).not.toBe( + (source.created as Tree).warning, + ); + }, + ); + + it('every locale provides both plural forms i18next falls back through', () => { + [en, ...Object.values(LOCALES)].forEach((locale) => { + const relative = ((locale.settings as Tree).accessTokens as Tree) + .relative as Tree; + expect(relative.days_one).toBeTruthy(); + expect(relative.days_other).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index e64ed52f..7291a332 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "Chatbots", - "name": "Name", - "key": "API-Schlüssel", - "sourceDoc": "Quelldokument", - "createNew": "Neu erstellen", - "noData": "Keine vorhandenen Chatbots", - "deleteConfirmation": "Bist du sicher, dass du den API-Schlüssel '{{name}}' löschen möchtest?", - "description": "Hier kannst du deine Chatbots erstellen und verwalten. Chatbots können als Widgets auf Websites eingebunden oder in deinen Anwendungen verwendet werden." - }, "analytics": { "label": "Analytik", "subtitle": "Nachrichtenvolumen, Token-Verbrauch und Nutzerfeedback in deinem Konto verfolgen", @@ -747,6 +737,104 @@ "testFailed": "Verbindungstest fehlgeschlagen" } }, + "accessTokens": { + "label": "Zugriffstoken", + "subtitle": "Mit persönlichen Zugriffstoken können Skripte, die CLI und CI-Pipelines die DocsGPT-API in deinem Namen aufrufen. Gib jedem Token nur die Berechtigungen, die es braucht.", + "createToken": "Token erstellen", + "disabledNotice": "Persönliche Zugriffstoken sind auf diesem Server nicht verfügbar. Sie setzen eine kontobasierte Anmeldung voraus und müssen von deinem Administrator aktiviert werden.", + "limitReached_one": "Du hast das Limit von {{count}} aktiven Token erreicht. Widerrufe eines, um ein neues zu erstellen.", + "limitReached_other": "Du hast das Limit von {{count}} aktiven Token erreicht. Widerrufe eines, um ein neues zu erstellen.", + "loadError": "Zugriffstoken konnten nicht geladen werden. Bitte versuche es erneut.", + "revokeError": "Das Token konnte nicht widerrufen werden. Bitte versuche es erneut.", + "empty": "Noch keine Zugriffstoken", + "emptyHint": "Erstelle ein Token, um die DocsGPT-API aus Skripten und Kommandozeilen-Tools zu nutzen.", + "name": "Name", + "scopes": "Berechtigungen", + "resources": "Ressourcen", + "createdAt": "Erstellt", + "lastUsed": "Zuletzt verwendet", + "expires": "Läuft ab", + "actions": "Aktionen", + "allResources": "Alle Ressourcen", + "never": "Nie", + "expired": "Abgelaufen am {{date}}", + "expiresSoon": "Läuft ab am {{date}}", + "moreScopes": "+{{count}} weitere", + "showLess": "Weniger anzeigen", + "revoke": "Widerrufen", + "revokeAria": "Token {{name}} widerrufen", + "revokeWarning": "Token „{{name}}“ widerrufen? Alles, was es noch verwendet, funktioniert sofort nicht mehr. Das kann nicht rückgängig gemacht werden.", + "restrictionCount": { + "agents_one": "{{count}} Agent", + "agents_other": "{{count}} Agenten", + "sources_one": "{{count}} Quelle", + "sources_other": "{{count}} Quellen", + "prompts_one": "{{count}} Prompt", + "prompts_other": "{{count}} Prompts", + "tools_one": "{{count}} Tool", + "tools_other": "{{count}} Tools", + "workflows_one": "{{count}} Workflow", + "workflows_other": "{{count}} Workflows" + }, + "relative": { + "now": "Gerade eben", + "minutes_one": "Vor {{count}} Minute", + "minutes_other": "Vor {{count}} Minuten", + "hours_one": "Vor {{count}} Stunde", + "hours_other": "Vor {{count}} Stunden", + "days_one": "Vor {{count}} Tag", + "days_other": "Vor {{count}} Tagen" + }, + "families": { + "agents": "Agenten", + "sources": "Quellen", + "prompts": "Prompts", + "tools": "Tools", + "models": "Modelle", + "workflows": "Workflows", + "schedules": "Zeitpläne", + "conversations": "Unterhaltungen", + "analytics": "Analysen", + "teams": "Teams", + "chat": "Chat" + }, + "create": { + "title": "Zugriffstoken erstellen", + "subtitle": "Das Token handelt in deinem Namen, beschränkt auf die Berechtigungen und Ressourcen, die du hier auswählst.", + "name": "Name", + "namePlaceholder": "z. B. CI-Pipeline", + "expiration": "Ablauf", + "expiryDays_one": "{{count}} Tag", + "expiryDays_other": "{{count}} Tage", + "noExpiration": "Kein Ablaufdatum", + "noExpirationHint": "Token ohne Ablaufdatum sind riskanter, falls sie in falsche Hände geraten. Ein Ablaufdatum ist die bessere Wahl.", + "expiresOn": "Läuft ab am {{date}}", + "scopes": "Berechtigungen", + "scopesHint": "Lege fest, was dieses Token darf. Schreibzugriff schließt Lesezugriff ein.", + "impliedByWrite": "Im Schreibzugriff enthalten", + "restrict": "Auf bestimmte Ressourcen beschränken", + "restrictHint": "Optional. Beschränke das Token auf die ausgewählten Elemente. Lass eine Liste leer, um alle zuzulassen.", + "restrictNoFamilies": "Wähle zuerst eine Berechtigung für Agenten, Quellen, Prompts, Tools oder Chat, um deren Ressourcen zu beschränken.", + "allSelected": "Alle (keine Beschränkung)", + "noResources": "Nichts gefunden.", + "searchResources": "Suchen...", + "resourcesError": "Die Liste konnte nicht geladen werden.", + "retry": "Erneut versuchen", + "cancel": "Abbrechen", + "submit": "Token erstellen", + "submitting": "Wird erstellt", + "error": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut." + }, + "created": { + "title": "Token erstellt", + "subtitle": "Dein neues Token „{{name}}“ ist einsatzbereit.", + "warning": "Kopiere das Token jetzt und bewahre es an einem sicheren Ort auf. Aus Sicherheitsgründen wird es nicht noch einmal angezeigt.", + "tokenLabel": "Dein Token", + "usageTitle": "So verwendest du es", + "usageHint": "Exportiere es als Umgebungsvariable und sende es als Bearer-Token im Authorization-Header.", + "done": "Fertig" + } + }, "scrollTabsLeft": "Tabs nach links scrollen", "tabsAriaLabel": "Einstellungs-Tabs", "scrollTabsRight": "Tabs nach rechts scrollen" diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 03f0f0dd..a5118073 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -325,16 +325,6 @@ } } }, - "apiKeys": { - "label": "Chatbots", - "name": "Name", - "key": "API Key", - "sourceDoc": "Source Document", - "createNew": "Create New", - "noData": "No existing Chatbots", - "deleteConfirmation": "Are you sure you want to delete the API key '{{name}}'?", - "description": "Here you can create and manage your chatbots. Chatbots can be deployed to websites as widgets or used inside your applications." - }, "analytics": { "label": "Analytics", "subtitle": "Track message volume, token usage, and user feedback across your account", @@ -752,6 +742,104 @@ "testFailed": "Connection test failed" } }, + "accessTokens": { + "label": "Access Tokens", + "subtitle": "Personal access tokens let scripts, the CLI and CI pipelines call the DocsGPT API on your behalf. Give each token only the scopes it needs.", + "createToken": "Create token", + "disabledNotice": "Personal access tokens are not available on this server. They require account-based sign-in and have to be enabled by your administrator.", + "limitReached_one": "You have reached the limit of {{count}} active token. Revoke one to create another.", + "limitReached_other": "You have reached the limit of {{count}} active tokens. Revoke one to create another.", + "loadError": "Failed to load access tokens. Please try again.", + "revokeError": "Failed to revoke the token. Please try again.", + "empty": "No access tokens yet", + "emptyHint": "Create a token to use the DocsGPT API from scripts and command-line tools.", + "name": "Name", + "scopes": "Scopes", + "resources": "Resources", + "createdAt": "Created", + "lastUsed": "Last used", + "expires": "Expires", + "actions": "Actions", + "allResources": "All resources", + "never": "Never", + "expired": "Expired {{date}}", + "expiresSoon": "Expires {{date}}", + "moreScopes": "+{{count}} more", + "showLess": "Show less", + "revoke": "Revoke", + "revokeAria": "Revoke token {{name}}", + "revokeWarning": "Revoke the token \"{{name}}\"? Anything still using it will stop working immediately. This cannot be undone.", + "restrictionCount": { + "agents_one": "{{count}} agent", + "agents_other": "{{count}} agents", + "sources_one": "{{count}} source", + "sources_other": "{{count}} sources", + "prompts_one": "{{count}} prompt", + "prompts_other": "{{count}} prompts", + "tools_one": "{{count}} tool", + "tools_other": "{{count}} tools", + "workflows_one": "{{count}} workflow", + "workflows_other": "{{count}} workflows" + }, + "relative": { + "now": "Just now", + "minutes_one": "{{count}} minute ago", + "minutes_other": "{{count}} minutes ago", + "hours_one": "{{count}} hour ago", + "hours_other": "{{count}} hours ago", + "days_one": "{{count}} day ago", + "days_other": "{{count}} days ago" + }, + "families": { + "agents": "Agents", + "sources": "Sources", + "prompts": "Prompts", + "tools": "Tools", + "models": "Models", + "workflows": "Workflows", + "schedules": "Schedules", + "conversations": "Conversations", + "analytics": "Analytics", + "teams": "Teams", + "chat": "Chat" + }, + "create": { + "title": "Create access token", + "subtitle": "The token acts as you, limited to the scopes and resources you pick here.", + "name": "Name", + "namePlaceholder": "e.g. CI pipeline", + "expiration": "Expiration", + "expiryDays_one": "{{count}} day", + "expiryDays_other": "{{count}} days", + "noExpiration": "No expiration", + "noExpirationHint": "Tokens that never expire are riskier if leaked. Prefer an expiration date.", + "expiresOn": "Expires on {{date}}", + "scopes": "Scopes", + "scopesHint": "Choose what this token may do. Write access includes read access.", + "impliedByWrite": "Included with write access", + "restrict": "Restrict to specific resources", + "restrictHint": "Optional. Limit the token to the items you select. Leave a list empty to allow all of them.", + "restrictNoFamilies": "Select a scope for agents, sources, prompts, tools or chat first to restrict its resources.", + "allSelected": "All (no restriction)", + "noResources": "Nothing found.", + "searchResources": "Search...", + "resourcesError": "Failed to load the list.", + "retry": "Retry", + "cancel": "Cancel", + "submit": "Create token", + "submitting": "Creating", + "error": "Failed to create the token. Please try again." + }, + "created": { + "title": "Token created", + "subtitle": "Your new token \"{{name}}\" is ready.", + "warning": "Copy the token now and store it somewhere safe. For security reasons it won't be shown again.", + "tokenLabel": "Your token", + "usageTitle": "How to use it", + "usageHint": "Export it as an environment variable, then send it as a Bearer token in the Authorization header.", + "done": "Done" + } + }, "scrollTabsLeft": "Scroll tabs left", "tabsAriaLabel": "Settings tabs", "scrollTabsRight": "Scroll tabs right" diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index c7b4f794..c6f7fc92 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "Chatbots", - "name": "Nombre", - "key": "Clave de API", - "sourceDoc": "Documento Fuente", - "createNew": "Crear Nuevo", - "noData": "No hay chatbots existentes", - "deleteConfirmation": "¿Estás seguro de que quieres eliminar la clave API '{{name}}'?", - "description": "Aquí puede crear y gestionar sus chatbots. Los chatbots se pueden implementar en sitios web como widgets o utilizarse dentro de sus aplicaciones." - }, "analytics": { "label": "Analítica", "subtitle": "Realiza un seguimiento del volumen de mensajes, uso de tokens y comentarios de usuarios en tu cuenta", @@ -747,6 +737,104 @@ "testFailed": "La prueba de conexión falló" } }, + "accessTokens": { + "label": "Tokens de acceso", + "subtitle": "Los tokens de acceso personal permiten que scripts, la CLI y pipelines de CI llamen a la API de DocsGPT en tu nombre. Da a cada token solo los permisos que necesite.", + "createToken": "Crear token", + "disabledNotice": "Los tokens de acceso personal no están disponibles en este servidor. Requieren inicio de sesión con cuenta y deben ser habilitados por tu administrador.", + "limitReached_one": "Has alcanzado el límite de {{count}} token activo. Revoca uno para crear otro.", + "limitReached_other": "Has alcanzado el límite de {{count}} tokens activos. Revoca uno para crear otro.", + "loadError": "No se pudieron cargar los tokens de acceso. Inténtalo de nuevo.", + "revokeError": "No se pudo revocar el token. Inténtalo de nuevo.", + "empty": "Aún no hay tokens de acceso", + "emptyHint": "Crea un token para usar la API de DocsGPT desde scripts y herramientas de línea de comandos.", + "name": "Nombre", + "scopes": "Permisos", + "resources": "Recursos", + "createdAt": "Creado", + "lastUsed": "Último uso", + "expires": "Caduca", + "actions": "Acciones", + "allResources": "Todos los recursos", + "never": "Nunca", + "expired": "Caducó el {{date}}", + "expiresSoon": "Caduca el {{date}}", + "moreScopes": "+{{count}} más", + "showLess": "Mostrar menos", + "revoke": "Revocar", + "revokeAria": "Revocar el token {{name}}", + "revokeWarning": "¿Revocar el token \"{{name}}\"? Todo lo que aún lo use dejará de funcionar de inmediato. Esta acción no se puede deshacer.", + "restrictionCount": { + "agents_one": "{{count}} agente", + "agents_other": "{{count}} agentes", + "sources_one": "{{count}} fuente", + "sources_other": "{{count}} fuentes", + "prompts_one": "{{count}} prompt", + "prompts_other": "{{count}} prompts", + "tools_one": "{{count}} herramienta", + "tools_other": "{{count}} herramientas", + "workflows_one": "{{count}} flujo de trabajo", + "workflows_other": "{{count}} flujos de trabajo" + }, + "relative": { + "now": "Ahora mismo", + "minutes_one": "Hace {{count}} minuto", + "minutes_other": "Hace {{count}} minutos", + "hours_one": "Hace {{count}} hora", + "hours_other": "Hace {{count}} horas", + "days_one": "Hace {{count}} día", + "days_other": "Hace {{count}} días" + }, + "families": { + "agents": "Agentes", + "sources": "Fuentes", + "prompts": "Prompts", + "tools": "Herramientas", + "models": "Modelos", + "workflows": "Flujos de trabajo", + "schedules": "Programaciones", + "conversations": "Conversaciones", + "analytics": "Analíticas", + "teams": "Equipos", + "chat": "Chat" + }, + "create": { + "title": "Crear token de acceso", + "subtitle": "El token actúa en tu nombre, limitado a los permisos y recursos que elijas aquí.", + "name": "Nombre", + "namePlaceholder": "p. ej. Pipeline de CI", + "expiration": "Caducidad", + "expiryDays_one": "{{count}} día", + "expiryDays_other": "{{count}} días", + "noExpiration": "Sin caducidad", + "noExpirationHint": "Los tokens que nunca caducan son más peligrosos si se filtran. Es preferible fijar una fecha de caducidad.", + "expiresOn": "Caduca el {{date}}", + "scopes": "Permisos", + "scopesHint": "Elige qué puede hacer este token. El acceso de escritura incluye el de lectura.", + "impliedByWrite": "Incluido con el acceso de escritura", + "restrict": "Restringir a recursos específicos", + "restrictHint": "Opcional. Limita el token a los elementos que selecciones. Deja una lista vacía para permitirlos todos.", + "restrictNoFamilies": "Selecciona primero un permiso de agentes, fuentes, prompts, herramientas o chat para restringir sus recursos.", + "allSelected": "Todos (sin restricción)", + "noResources": "No se encontró nada.", + "searchResources": "Buscar...", + "resourcesError": "No se pudo cargar la lista.", + "retry": "Reintentar", + "cancel": "Cancelar", + "submit": "Crear token", + "submitting": "Creando", + "error": "No se pudo crear el token. Inténtalo de nuevo." + }, + "created": { + "title": "Token creado", + "subtitle": "Tu nuevo token \"{{name}}\" está listo.", + "warning": "Copia el token ahora y guárdalo en un lugar seguro. Por motivos de seguridad no se volverá a mostrar.", + "tokenLabel": "Tu token", + "usageTitle": "Cómo usarlo", + "usageHint": "Expórtalo como variable de entorno y envíalo como token Bearer en la cabecera Authorization.", + "done": "Listo" + } + }, "scrollTabsLeft": "Desplazar pestañas a la izquierda", "tabsAriaLabel": "Pestañas de configuración", "scrollTabsRight": "Desplazar pestañas a la derecha" diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index dc022fe8..8c0bfc73 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "チャットボット", - "name": "名前", - "key": "APIキー", - "sourceDoc": "ソースドキュメント", - "createNew": "新規作成", - "noData": "既存のチャットボットはありません", - "deleteConfirmation": "APIキー '{{name}}' を削除してもよろしいですか?", - "description": "ここでチャットボットを作成・管理できます。チャットボットはウィジェットとしてウェブサイトに導入したり、アプリケーション内で使用したりすることができます。" - }, "analytics": { "label": "分析", "subtitle": "アカウント全体のメッセージ量、トークン使用量、ユーザーフィードバックを追跡", @@ -747,6 +737,104 @@ "testFailed": "接続テストに失敗しました" } }, + "accessTokens": { + "label": "アクセストークン", + "subtitle": "個人用アクセストークンを使うと、スクリプト、CLI、CIパイプラインがあなたに代わってDocsGPT APIを呼び出せます。各トークンには必要なスコープだけを付与してください。", + "createToken": "トークンを作成", + "disabledNotice": "このサーバーでは個人用アクセストークンを利用できません。アカウントでのサインインが必要で、管理者が有効にする必要があります。", + "limitReached_one": "有効なトークンが上限の{{count}}個に達しました。新しく作成するには、いずれかを取り消してください。", + "limitReached_other": "有効なトークンが上限の{{count}}個に達しました。新しく作成するには、いずれかを取り消してください。", + "loadError": "アクセストークンを読み込めませんでした。もう一度お試しください。", + "revokeError": "トークンを取り消せませんでした。もう一度お試しください。", + "empty": "アクセストークンはまだありません", + "emptyHint": "トークンを作成すると、スクリプトやコマンドラインツールからDocsGPT APIを利用できます。", + "name": "名前", + "scopes": "スコープ", + "resources": "リソース", + "createdAt": "作成日", + "lastUsed": "最終使用", + "expires": "有効期限", + "actions": "操作", + "allResources": "すべてのリソース", + "never": "なし", + "expired": "{{date}}に期限切れ", + "expiresSoon": "{{date}}に期限切れ予定", + "moreScopes": "他{{count}}件", + "showLess": "折りたたむ", + "revoke": "取り消し", + "revokeAria": "トークン{{name}}を取り消す", + "revokeWarning": "トークン「{{name}}」を取り消しますか?このトークンを使用しているものはすぐに動作しなくなります。この操作は元に戻せません。", + "restrictionCount": { + "agents_one": "エージェント{{count}}件", + "agents_other": "エージェント{{count}}件", + "sources_one": "ソース{{count}}件", + "sources_other": "ソース{{count}}件", + "prompts_one": "プロンプト{{count}}件", + "prompts_other": "プロンプト{{count}}件", + "tools_one": "ツール{{count}}件", + "tools_other": "ツール{{count}}件", + "workflows_one": "ワークフロー{{count}}件", + "workflows_other": "ワークフロー{{count}}件" + }, + "relative": { + "now": "たった今", + "minutes_one": "{{count}}分前", + "minutes_other": "{{count}}分前", + "hours_one": "{{count}}時間前", + "hours_other": "{{count}}時間前", + "days_one": "{{count}}日前", + "days_other": "{{count}}日前" + }, + "families": { + "agents": "エージェント", + "sources": "ソース", + "prompts": "プロンプト", + "tools": "ツール", + "models": "モデル", + "workflows": "ワークフロー", + "schedules": "スケジュール", + "conversations": "会話", + "analytics": "分析", + "teams": "チーム", + "chat": "チャット" + }, + "create": { + "title": "アクセストークンを作成", + "subtitle": "トークンはあなたとして動作し、ここで選択したスコープとリソースに制限されます。", + "name": "名前", + "namePlaceholder": "例: CIパイプライン", + "expiration": "有効期限", + "expiryDays_one": "{{count}}日", + "expiryDays_other": "{{count}}日", + "noExpiration": "無期限", + "noExpirationHint": "無期限のトークンは漏洩した場合のリスクが高くなります。有効期限の設定をおすすめします。", + "expiresOn": "{{date}}に期限切れ", + "scopes": "スコープ", + "scopesHint": "このトークンに許可する操作を選択します。書き込み権限には読み取り権限が含まれます。", + "impliedByWrite": "書き込み権限に含まれます", + "restrict": "特定のリソースに制限する", + "restrictHint": "任意。選択した項目だけにトークンを制限します。リストを空のままにすると、すべて許可されます。", + "restrictNoFamilies": "リソースを制限するには、まずエージェント、ソース、プロンプト、ツール、またはチャットのスコープを選択してください。", + "allSelected": "すべて(制限なし)", + "noResources": "見つかりませんでした。", + "searchResources": "検索...", + "resourcesError": "リストを読み込めませんでした。", + "retry": "再試行", + "cancel": "キャンセル", + "submit": "トークンを作成", + "submitting": "作成中", + "error": "トークンを作成できませんでした。もう一度お試しください。" + }, + "created": { + "title": "トークンを作成しました", + "subtitle": "新しいトークン「{{name}}」の準備ができました。", + "warning": "今すぐトークンをコピーして、安全な場所に保管してください。セキュリティ上の理由から、再度表示されることはありません。", + "tokenLabel": "あなたのトークン", + "usageTitle": "使い方", + "usageHint": "環境変数としてエクスポートし、AuthorizationヘッダーのBearerトークンとして送信します。", + "done": "完了" + } + }, "scrollTabsLeft": "タブを左にスクロール", "tabsAriaLabel": "設定タブ", "scrollTabsRight": "タブを右にスクロール" diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index dd7aef1e..ff29d056 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "API ключи", - "name": "Название", - "key": "API ключ", - "sourceDoc": "Источник документа", - "createNew": "Создать новый", - "noData": "Нет существующих чатботов", - "deleteConfirmation": "Вы уверены, что хотите удалить API ключ '{{name}}'?", - "description": "Здесь вы можете создавать и управлять чат-ботами. Чат-боты могут быть развернуты на веб-сайтах в виде виджетов или использоваться внутри ваших приложений." - }, "analytics": { "label": "Аналитика", "subtitle": "Отслеживайте объём сообщений, использование токенов и отзывы пользователей в вашем аккаунте", @@ -747,6 +737,124 @@ "testFailed": "Проверка соединения не удалась" } }, + "accessTokens": { + "label": "Токены доступа", + "subtitle": "Персональные токены доступа позволяют скриптам, CLI и CI-конвейерам обращаться к API DocsGPT от вашего имени. Выдавайте каждому токену только необходимые права.", + "createToken": "Создать токен", + "disabledNotice": "Персональные токены доступа недоступны на этом сервере. Для них нужен вход через учётную запись, и их должен включить администратор.", + "limitReached_one": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "limitReached_few": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "limitReached_many": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "limitReached_other": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.", + "loadError": "Не удалось загрузить токены доступа. Попробуйте ещё раз.", + "revokeError": "Не удалось отозвать токен. Попробуйте ещё раз.", + "empty": "Токенов доступа пока нет", + "emptyHint": "Создайте токен, чтобы использовать API DocsGPT из скриптов и инструментов командной строки.", + "name": "Название", + "scopes": "Права", + "resources": "Ресурсы", + "createdAt": "Создан", + "lastUsed": "Последнее использование", + "expires": "Истекает", + "actions": "Действия", + "allResources": "Все ресурсы", + "never": "Никогда", + "expired": "Истёк {{date}}", + "expiresSoon": "Истекает {{date}}", + "moreScopes": "ещё {{count}}", + "showLess": "Свернуть", + "revoke": "Отозвать", + "revokeAria": "Отозвать токен {{name}}", + "revokeWarning": "Отозвать токен «{{name}}»? Всё, что его использует, сразу перестанет работать. Это действие нельзя отменить.", + "restrictionCount": { + "agents_one": "{{count}} агент", + "agents_few": "{{count}} агента", + "agents_many": "{{count}} агентов", + "agents_other": "{{count}} агента", + "sources_one": "{{count}} источник", + "sources_few": "{{count}} источника", + "sources_many": "{{count}} источников", + "sources_other": "{{count}} источника", + "prompts_one": "{{count}} промпт", + "prompts_few": "{{count}} промпта", + "prompts_many": "{{count}} промптов", + "prompts_other": "{{count}} промпта", + "tools_one": "{{count}} инструмент", + "tools_few": "{{count}} инструмента", + "tools_many": "{{count}} инструментов", + "tools_other": "{{count}} инструмента", + "workflows_one": "{{count}} рабочий процесс", + "workflows_few": "{{count}} рабочих процесса", + "workflows_many": "{{count}} рабочих процессов", + "workflows_other": "{{count}} рабочего процесса" + }, + "relative": { + "now": "Только что", + "minutes_one": "{{count}} минуту назад", + "minutes_few": "{{count}} минуты назад", + "minutes_many": "{{count}} минут назад", + "minutes_other": "{{count}} минуты назад", + "hours_one": "{{count}} час назад", + "hours_few": "{{count}} часа назад", + "hours_many": "{{count}} часов назад", + "hours_other": "{{count}} часа назад", + "days_one": "{{count}} день назад", + "days_few": "{{count}} дня назад", + "days_many": "{{count}} дней назад", + "days_other": "{{count}} дня назад" + }, + "families": { + "agents": "Агенты", + "sources": "Источники", + "prompts": "Промпты", + "tools": "Инструменты", + "models": "Модели", + "workflows": "Рабочие процессы", + "schedules": "Расписания", + "conversations": "Диалоги", + "analytics": "Аналитика", + "teams": "Команды", + "chat": "Чат" + }, + "create": { + "title": "Создать токен доступа", + "subtitle": "Токен действует от вашего имени в пределах прав и ресурсов, которые вы выберете здесь.", + "name": "Название", + "namePlaceholder": "например, CI-конвейер", + "expiration": "Срок действия", + "expiryDays_one": "{{count}} день", + "expiryDays_few": "{{count}} дня", + "expiryDays_many": "{{count}} дней", + "expiryDays_other": "{{count}} дня", + "noExpiration": "Бессрочно", + "noExpirationHint": "Бессрочные токены опаснее при утечке. Лучше задать срок действия.", + "expiresOn": "Истекает {{date}}", + "scopes": "Права", + "scopesHint": "Выберите, что разрешено этому токену. Право на запись включает право на чтение.", + "impliedByWrite": "Входит в право на запись", + "restrict": "Ограничить определёнными ресурсами", + "restrictHint": "Необязательно. Ограничьте токен выбранными элементами. Оставьте список пустым, чтобы разрешить все.", + "restrictNoFamilies": "Сначала выберите право для агентов, источников, промптов, инструментов или чата, чтобы ограничить их ресурсы.", + "allSelected": "Все (без ограничений)", + "noResources": "Ничего не найдено.", + "searchResources": "Поиск...", + "resourcesError": "Не удалось загрузить список.", + "retry": "Повторить", + "cancel": "Отмена", + "submit": "Создать токен", + "submitting": "Создание", + "error": "Не удалось создать токен. Попробуйте ещё раз." + }, + "created": { + "title": "Токен создан", + "subtitle": "Ваш новый токен «{{name}}» готов.", + "warning": "Скопируйте токен сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.", + "tokenLabel": "Ваш токен", + "usageTitle": "Как использовать", + "usageHint": "Экспортируйте его как переменную окружения и передавайте как Bearer-токен в заголовке Authorization.", + "done": "Готово" + } + }, "scrollTabsLeft": "Прокрутить вкладки влево", "tabsAriaLabel": "Вкладки настроек", "scrollTabsRight": "Прокрутить вкладки вправо" diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 16236cec..1952b6e5 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "聊天機器人", - "name": "名稱", - "key": "API 金鑰", - "sourceDoc": "來源文件", - "createNew": "建立新的", - "noData": "沒有現有的聊天機器人", - "deleteConfirmation": "您確定要刪除 API 金鑰 '{{name}}' 嗎?", - "description": "在這裡,您可以創建和管理您的聊天機器人。聊天機器人可以作為小部件部署到網站上,或在您的應用程序中使用。" - }, "analytics": { "label": "分析", "subtitle": "追蹤您賃戶的訊息量、Token 使用量和使用者回饋", @@ -747,6 +737,104 @@ "testFailed": "連線測試失敗" } }, + "accessTokens": { + "label": "存取權杖", + "subtitle": "個人存取權杖可讓指令碼、CLI 和 CI 管線以您的身分呼叫 DocsGPT API。請只授予每個權杖所需的權限範圍。", + "createToken": "建立權杖", + "disabledNotice": "此伺服器不支援個人存取權杖。此功能需要使用帳戶登入,並須由管理員啟用。", + "limitReached_one": "您的有效權杖已達 {{count}} 個的上限。請先撤銷一個再建立新的權杖。", + "limitReached_other": "您的有效權杖已達 {{count}} 個的上限。請先撤銷一個再建立新的權杖。", + "loadError": "載入存取權杖失敗,請再試一次。", + "revokeError": "撤銷權杖失敗,請再試一次。", + "empty": "尚無存取權杖", + "emptyHint": "建立權杖後,即可在指令碼和命令列工具中使用 DocsGPT API。", + "name": "名稱", + "scopes": "權限範圍", + "resources": "資源", + "createdAt": "建立時間", + "lastUsed": "上次使用", + "expires": "到期時間", + "actions": "操作", + "allResources": "所有資源", + "never": "從不", + "expired": "已於 {{date}} 到期", + "expiresSoon": "將於 {{date}} 到期", + "moreScopes": "另外 {{count}} 項", + "showLess": "收合", + "revoke": "撤銷", + "revokeAria": "撤銷權杖 {{name}}", + "revokeWarning": "確定要撤銷權杖「{{name}}」嗎?所有仍在使用它的程式將立即失效,且此操作無法復原。", + "restrictionCount": { + "agents_one": "{{count}} 個代理", + "agents_other": "{{count}} 個代理", + "sources_one": "{{count}} 個來源", + "sources_other": "{{count}} 個來源", + "prompts_one": "{{count}} 個提示詞", + "prompts_other": "{{count}} 個提示詞", + "tools_one": "{{count}} 個工具", + "tools_other": "{{count}} 個工具", + "workflows_one": "{{count}} 個工作流程", + "workflows_other": "{{count}} 個工作流程" + }, + "relative": { + "now": "剛剛", + "minutes_one": "{{count}} 分鐘前", + "minutes_other": "{{count}} 分鐘前", + "hours_one": "{{count}} 小時前", + "hours_other": "{{count}} 小時前", + "days_one": "{{count}} 天前", + "days_other": "{{count}} 天前" + }, + "families": { + "agents": "代理", + "sources": "來源", + "prompts": "提示詞", + "tools": "工具", + "models": "模型", + "workflows": "工作流程", + "schedules": "排程", + "conversations": "對話", + "analytics": "分析", + "teams": "團隊", + "chat": "聊天" + }, + "create": { + "title": "建立存取權杖", + "subtitle": "權杖將以您的身分操作,但僅限於您在此選擇的權限範圍和資源。", + "name": "名稱", + "namePlaceholder": "例如:CI 管線", + "expiration": "有效期限", + "expiryDays_one": "{{count}} 天", + "expiryDays_other": "{{count}} 天", + "noExpiration": "永不到期", + "noExpirationHint": "永不到期的權杖一旦外洩風險更高,建議設定到期時間。", + "expiresOn": "將於 {{date}} 到期", + "scopes": "權限範圍", + "scopesHint": "選擇此權杖可以執行的操作。寫入權限包含讀取權限。", + "impliedByWrite": "已包含在寫入權限中", + "restrict": "限制為特定資源", + "restrictHint": "選填。將權杖限制為您選擇的項目。清單留空表示允許全部。", + "restrictNoFamilies": "請先選擇代理、來源、提示詞、工具或聊天的權限範圍,才能限制其資源。", + "allSelected": "全部(不限制)", + "noResources": "找不到任何內容。", + "searchResources": "搜尋...", + "resourcesError": "載入清單失敗。", + "retry": "重試", + "cancel": "取消", + "submit": "建立權杖", + "submitting": "建立中", + "error": "建立權杖失敗,請再試一次。" + }, + "created": { + "title": "權杖已建立", + "subtitle": "您的新權杖「{{name}}」已就緒。", + "warning": "請立即複製權杖並妥善保存。基於安全考量,它不會再次顯示。", + "tokenLabel": "您的權杖", + "usageTitle": "使用方式", + "usageHint": "將其匯出為環境變數,然後在 Authorization 標頭中作為 Bearer 權杖傳送。", + "done": "完成" + } + }, "scrollTabsLeft": "向左捲動標籤", "tabsAriaLabel": "設定標籤", "scrollTabsRight": "向右捲動標籤" diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 5b4cf5a6..74316ec5 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -321,16 +321,6 @@ } } }, - "apiKeys": { - "label": "聊天机器人", - "name": "名称", - "key": "API 密钥", - "sourceDoc": "源文档", - "createNew": "创建新的", - "noData": "没有现有的聊天机器人", - "deleteConfirmation": "您确定要删除 API 密钥 '{{name}}' 吗?", - "description": "在这里,您可以创建和管理您的聊天机器人。聊天机器人可以作为小部件部署到网站上,或在您的应用程序中使用。" - }, "analytics": { "label": "分析", "subtitle": "跟踪您账户的消息量、令牌使用情况和用户反馈", @@ -747,6 +737,104 @@ "testFailed": "连接测试失败" } }, + "accessTokens": { + "label": "访问令牌", + "subtitle": "个人访问令牌可让脚本、CLI 和 CI 流水线以您的身份调用 DocsGPT API。请只为每个令牌授予其所需的权限范围。", + "createToken": "创建令牌", + "disabledNotice": "此服务器不支持个人访问令牌。该功能需要使用账户登录,并须由管理员启用。", + "limitReached_one": "您的有效令牌已达到 {{count}} 个的上限。请先撤销一个再创建新的令牌。", + "limitReached_other": "您的有效令牌已达到 {{count}} 个的上限。请先撤销一个再创建新的令牌。", + "loadError": "加载访问令牌失败,请重试。", + "revokeError": "撤销令牌失败,请重试。", + "empty": "暂无访问令牌", + "emptyHint": "创建令牌后,即可在脚本和命令行工具中使用 DocsGPT API。", + "name": "名称", + "scopes": "权限范围", + "resources": "资源", + "createdAt": "创建时间", + "lastUsed": "上次使用", + "expires": "过期时间", + "actions": "操作", + "allResources": "所有资源", + "never": "从不", + "expired": "已于 {{date}} 过期", + "expiresSoon": "将于 {{date}} 过期", + "moreScopes": "另外 {{count}} 项", + "showLess": "收起", + "revoke": "撤销", + "revokeAria": "撤销令牌 {{name}}", + "revokeWarning": "确定要撤销令牌“{{name}}”吗?所有仍在使用它的程序将立即失效,且此操作无法撤回。", + "restrictionCount": { + "agents_one": "{{count}} 个代理", + "agents_other": "{{count}} 个代理", + "sources_one": "{{count}} 个来源", + "sources_other": "{{count}} 个来源", + "prompts_one": "{{count}} 个提示词", + "prompts_other": "{{count}} 个提示词", + "tools_one": "{{count}} 个工具", + "tools_other": "{{count}} 个工具", + "workflows_one": "{{count}} 个工作流", + "workflows_other": "{{count}} 个工作流" + }, + "relative": { + "now": "刚刚", + "minutes_one": "{{count}} 分钟前", + "minutes_other": "{{count}} 分钟前", + "hours_one": "{{count}} 小时前", + "hours_other": "{{count}} 小时前", + "days_one": "{{count}} 天前", + "days_other": "{{count}} 天前" + }, + "families": { + "agents": "代理", + "sources": "来源", + "prompts": "提示词", + "tools": "工具", + "models": "模型", + "workflows": "工作流", + "schedules": "计划任务", + "conversations": "对话", + "analytics": "分析", + "teams": "团队", + "chat": "聊天" + }, + "create": { + "title": "创建访问令牌", + "subtitle": "令牌将以您的身份操作,但仅限于您在此选择的权限范围和资源。", + "name": "名称", + "namePlaceholder": "例如:CI 流水线", + "expiration": "有效期", + "expiryDays_one": "{{count}} 天", + "expiryDays_other": "{{count}} 天", + "noExpiration": "永不过期", + "noExpirationHint": "永不过期的令牌一旦泄露风险更高,建议设置过期时间。", + "expiresOn": "将于 {{date}} 过期", + "scopes": "权限范围", + "scopesHint": "选择此令牌可以执行的操作。写入权限包含读取权限。", + "impliedByWrite": "已包含在写入权限中", + "restrict": "限制为特定资源", + "restrictHint": "可选。将令牌限制为您选择的项目。列表留空表示允许全部。", + "restrictNoFamilies": "请先选择代理、来源、提示词、工具或聊天的权限范围,才能限制其资源。", + "allSelected": "全部(不限制)", + "noResources": "未找到任何内容。", + "searchResources": "搜索...", + "resourcesError": "加载列表失败。", + "retry": "重试", + "cancel": "取消", + "submit": "创建令牌", + "submitting": "正在创建", + "error": "创建令牌失败,请重试。" + }, + "created": { + "title": "令牌已创建", + "subtitle": "您的新令牌“{{name}}”已就绪。", + "warning": "请立即复制令牌并妥善保存。出于安全考虑,它不会再次显示。", + "tokenLabel": "您的令牌", + "usageTitle": "使用方法", + "usageHint": "将其导出为环境变量,然后在 Authorization 请求头中作为 Bearer 令牌发送。", + "done": "完成" + } + }, "scrollTabsLeft": "向左滚动标签", "tabsAriaLabel": "设置标签", "scrollTabsRight": "向右滚动标签" diff --git a/frontend/src/modals/AccessTokenCreatedModal.tsx b/frontend/src/modals/AccessTokenCreatedModal.tsx new file mode 100644 index 00000000..66026edc --- /dev/null +++ b/frontend/src/modals/AccessTokenCreatedModal.tsx @@ -0,0 +1,104 @@ +import { TriangleAlert } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { baseURL } from '../api/client'; +import CopyButton from '../components/CopyButton'; +import { Button } from '../components/ui/button'; +import { Modal } from '../components/ui/modal'; + +interface AccessTokenCreatedModalProps { + /** Plaintext secret; `null` keeps the modal closed. Held only in the parent's component state. */ + token: string | null; + name: string; + onClose: () => void; +} + +export default function AccessTokenCreatedModal({ + token, + name, + onClose, +}: AccessTokenCreatedModalProps) { + const { t } = useTranslation(); + const exportSnippet = `export DOCSGPT_TOKEN=${token ?? ''}`; + const curlSnippet = `curl -H "Authorization: Bearer $DOCSGPT_TOKEN" ${baseURL}/api/get_agents`; + + return ( + !o && onClose()} + hideTitle + title={t('settings.accessTokens.created.title')} + size="lg" + mobileVariant="sheet" + // The secret cannot be shown again, so a stray click outside must not + // dismiss it; closing takes the explicit button (or Esc). + isPerformingTask + footer={ + + } + > +
+
+

+ {t('settings.accessTokens.created.title')} +

+

+ {t('settings.accessTokens.created.subtitle', { name })} +

+
+ +
+
+ +
+

+ {t('settings.accessTokens.created.tokenLabel')} +

+
+ + {token} + + {token && } +
+
+ +
+

+ {t('settings.accessTokens.created.usageTitle')} +

+

+ {t('settings.accessTokens.created.usageHint')} +

+ {[exportSnippet, curlSnippet].map((snippet, index) => ( +
+
+                {snippet}
+              
+ +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/modals/CreateAccessTokenModal.tsx b/frontend/src/modals/CreateAccessTokenModal.tsx new file mode 100644 index 00000000..438403a8 --- /dev/null +++ b/frontend/src/modals/CreateAccessTokenModal.tsx @@ -0,0 +1,462 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import patService, { + AccessTokenPolicy, + AccessTokenScope, + CreateAccessTokenResponse, +} from '../api/services/patService'; +import userService from '../api/services/userService'; +import Spinner from '../components/Spinner'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Label } from '../components/ui/label'; +import { Modal } from '../components/ui/modal'; +import { MultiSelect } from '../components/ui/multi-select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; +import { Switch } from '../components/ui/switch'; +import { selectToken } from '../preferences/preferenceSlice'; +import { + buildResourceFilter, + defaultExpiry, + eligibleFilterFamilies, + expiryOptions, + groupScopesByFamily, + isScopeImplied, + NO_EXPIRY, + PICKER_FAMILIES, + ResourceOption, + scopesToSubmit, + toResourceOptions, +} from '../settings/accessTokenUtils'; +import { formatDateOnly } from '../utils/dateTimeUtils'; + +const MAX_NAME_LENGTH = 100; +const DAY_MS = 24 * 60 * 60 * 1000; + +type ResourceState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'ready'; options: ResourceOption[] }; + +const RESOURCE_FETCHERS: Record< + string, + (token: string | null) => Promise +> = { + agents: (token) => userService.getAgents(token), + sources: (token) => userService.getDocs(token), + prompts: (token) => userService.getPrompts(token), + tools: (token) => userService.getUserTools(token), +}; + +interface CreateAccessTokenModalProps { + open: boolean; + onClose: () => void; + scopes: AccessTokenScope[]; + policy: AccessTokenPolicy; + onCreated: (created: CreateAccessTokenResponse) => void; +} + +export default function CreateAccessTokenModal({ + open, + onClose, + scopes, + policy, + onCreated, +}: CreateAccessTokenModalProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + + const [name, setName] = React.useState(''); + const [selectedScopes, setSelectedScopes] = React.useState([]); + const [expiry, setExpiry] = React.useState(() => + defaultExpiry(policy), + ); + const [restrict, setRestrict] = React.useState(false); + const [resourceSelection, setResourceSelection] = React.useState< + Record + >({}); + const [resources, setResources] = React.useState< + Record + >({}); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + + // Start from a clean form on every open; resource lists are refetched so a + // freshly created agent/source shows up without a page reload. + React.useEffect(() => { + if (!open) return; + setName(''); + setSelectedScopes([]); + setExpiry(defaultExpiry(policy)); + setRestrict(false); + setResourceSelection({}); + setResources({}); + setSubmitting(false); + setError(null); + }, [open, policy]); + + const scopeGroups = React.useMemo( + () => groupScopesByFamily(scopes), + [scopes], + ); + const expiryChoices = React.useMemo(() => expiryOptions(policy), [policy]); + const pickerFamilies = React.useMemo( + () => + eligibleFilterFamilies(selectedScopes, policy.filterable_families).filter( + (family) => PICKER_FAMILIES.includes(family), + ), + [selectedScopes, policy.filterable_families], + ); + + const loadResources = React.useCallback( + (family: string) => { + const fetcher = RESOURCE_FETCHERS[family]; + if (!fetcher) return; + setResources((prev) => ({ ...prev, [family]: { status: 'loading' } })); + fetcher(token) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const options = toResourceOptions(family, await response.json()); + setResources((prev) => ({ + ...prev, + [family]: { status: 'ready', options }, + })); + }) + .catch((err) => { + console.error(`Failed to load ${family}:`, err); + setResources((prev) => ({ ...prev, [family]: { status: 'error' } })); + }); + }, + [token], + ); + + // Lazy: a family's list is only fetched once its picker is actually shown. + React.useEffect(() => { + if (!open || !restrict) return; + pickerFamilies.forEach((family) => { + if (!resources[family]) loadResources(family); + }); + }, [open, restrict, pickerFamilies, resources, loadResources]); + + const toggleScope = (scope: string) => { + setError(null); + setSelectedScopes((prev) => + prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope], + ); + }; + + const familyLabel = (family: string) => + t(`settings.accessTokens.families.${family}`, { defaultValue: family }); + + const trimmedName = name.trim(); + const canSubmit = + trimmedName.length > 0 && selectedScopes.length > 0 && !submitting; + + const handleClose = () => { + if (submitting) return; + onClose(); + }; + + const handleSubmit = async () => { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + const created = await patService.create( + { + name: trimmedName, + scopes: scopesToSubmit(selectedScopes, scopes), + resource_filter: restrict + ? buildResourceFilter(resourceSelection, pickerFamilies) + : undefined, + expires_in_days: expiry, + }, + token, + ); + onCreated(created); + } catch (err) { + const message = err instanceof Error ? err.message : ''; + setError(message || t('settings.accessTokens.create.error')); + } finally { + setSubmitting(false); + } + }; + + const expiryLabel = (days: number) => + days === NO_EXPIRY + ? t('settings.accessTokens.create.noExpiration') + : t('settings.accessTokens.create.expiryDays', { count: days }); + + return ( + !o && handleClose()} + hideTitle + title={t('settings.accessTokens.create.title')} + size="lg" + mobileVariant="sheet" + isPerformingTask={submitting} + contentClassName="max-h-[65vh]" + footer={ + <> + + + + } + > +
{ + e.preventDefault(); + handleSubmit(); + }} + > +
+

+ {t('settings.accessTokens.create.title')} +

+

+ {t('settings.accessTokens.create.subtitle')} +

+
+ +
+
+ + { + setName(e.target.value); + setError(null); + }} + placeholder={t('settings.accessTokens.create.namePlaceholder')} + className="rounded-xl" + autoComplete="off" + /> +
+
+ + +

+ {expiry === NO_EXPIRY + ? t('settings.accessTokens.create.noExpirationHint') + : t('settings.accessTokens.create.expiresOn', { + date: formatDateOnly( + new Date(Date.now() + expiry * DAY_MS).toISOString(), + ), + })} +

+
+
+ +
+ + {t('settings.accessTokens.create.scopes')} + * + +

+ {t('settings.accessTokens.create.scopesHint')} +

+
+ {scopeGroups.map((group) => ( +
+

+ {familyLabel(group.family)} +

+
+ {group.scopes.map((scope) => { + const implied = isScopeImplied(scope.name, selectedScopes); + const checked = + implied || selectedScopes.includes(scope.name); + const id = `pat-scope-${scope.name}`; + return ( + + ); + })} +
+
+ ))} +
+
+ + {policy.filterable_families.length > 0 && ( +
+
+
+ +

+ {t('settings.accessTokens.create.restrictHint')} +

+
+ +
+ {restrict && pickerFamilies.length === 0 && ( +

+ {t('settings.accessTokens.create.restrictNoFamilies')} +

+ )} + {restrict && + pickerFamilies.map((family) => { + const state = resources[family]; + return ( +
+ + {!state || state.status === 'loading' ? ( +
+ +
+ ) : state.status === 'error' ? ( +
+ + {t('settings.accessTokens.create.resourcesError')} + + +
+ ) : ( + + setResourceSelection((prev) => ({ + ...prev, + [family]: ids, + })) + } + placeholder={t( + 'settings.accessTokens.create.allSelected', + )} + emptyText={t( + 'settings.accessTokens.create.noResources', + )} + searchPlaceholder={t( + 'settings.accessTokens.create.searchResources', + )} + className="rounded-xl" + /> + )} +
+ ); + })} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/frontend/src/settings/PersonalAccessTokens.tsx b/frontend/src/settings/PersonalAccessTokens.tsx new file mode 100644 index 00000000..d81416b0 --- /dev/null +++ b/frontend/src/settings/PersonalAccessTokens.tsx @@ -0,0 +1,443 @@ +import { TriangleAlert } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import patService, { + AccessTokenPolicy, + AccessTokenScope, + CreateAccessTokenResponse, + PersonalAccessToken, +} from '../api/services/patService'; +import NoFilesDarkIcon from '../assets/no-files-dark.svg'; +import NoFilesIcon from '../assets/no-files.svg'; +import SkeletonLoader from '../components/SkeletonLoader'; +import { Alert, AlertDescription } from '../components/ui/alert'; +import { Button } from '../components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import { useDarkTheme } from '../hooks'; +import AccessTokenCreatedModal from '../modals/AccessTokenCreatedModal'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import CreateAccessTokenModal from '../modals/CreateAccessTokenModal'; +import { ActiveState } from '../models/misc'; +import { selectToken } from '../preferences/preferenceSlice'; +import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils'; +import { + countLiveTokens, + expiryStatus, + relativeTime, + restrictionCounts, +} from './accessTokenUtils'; + +const VISIBLE_SCOPES = 3; + +function ScopeChips({ scopes }: { scopes: string[] }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = React.useState(false); + const visible = expanded ? scopes : scopes.slice(0, VISIBLE_SCOPES); + const hidden = scopes.length - visible.length; + + return ( +
+ {visible.map((scope) => ( + + {scope} + + ))} + {scopes.length > VISIBLE_SCOPES && ( + + )} +
+ ); +} + +export default function PersonalAccessTokens() { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [isDarkTheme] = useDarkTheme(); + + const [tokens, setTokens] = React.useState([]); + const [scopes, setScopes] = React.useState([]); + const [policy, setPolicy] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [createOpen, setCreateOpen] = React.useState(false); + // The plaintext secret lives only here, and only until the modal closes. + const [created, setCreated] = + React.useState(null); + const [revokeState, setRevokeState] = React.useState('INACTIVE'); + const [tokenToRevoke, setTokenToRevoke] = + React.useState(null); + + const loadTokens = React.useCallback( + async (showLoader: boolean) => { + if (showLoader) setLoading(true); + try { + const data = await patService.list(token); + setTokens(data.tokens ?? []); + setScopes(data.scopes ?? []); + setPolicy(data.policy ?? null); + setError(null); + } catch (err) { + console.error('Failed to load access tokens:', err); + setError(t('settings.accessTokens.loadError')); + } finally { + setLoading(false); + } + }, + [token, t], + ); + + React.useEffect(() => { + loadTokens(true); + }, [loadTokens]); + + const handleCreated = (response: CreateAccessTokenResponse) => { + setCreateOpen(false); + setCreated(response); + setTokens((prev) => [response.personal_access_token, ...prev]); + }; + + const requestRevoke = (item: PersonalAccessToken) => { + setTokenToRevoke(item); + setRevokeState('ACTIVE'); + }; + + const confirmRevoke = async () => { + if (!tokenToRevoke) return; + const target = tokenToRevoke; + try { + await patService.revoke(target.id, token); + setTokens((prev) => prev.filter((item) => item.id !== target.id)); + setError(null); + } catch (err) { + console.error('Failed to revoke access token:', err); + setError( + (err instanceof Error && err.message) || + t('settings.accessTokens.revokeError'), + ); + } finally { + setTokenToRevoke(null); + } + }; + + const limitReached = + !!policy && countLiveTokens(tokens) >= policy.max_per_user; + + const renderRestrictions = (item: PersonalAccessToken) => { + const counts = restrictionCounts(item.resource_filter); + if (counts.length === 0) return t('settings.accessTokens.allResources'); + return counts + .map(({ family, count }) => + t(`settings.accessTokens.restrictionCount.${family}`, { + count, + defaultValue: `${count} ${family}`, + }), + ) + .join(', '); + }; + + const renderLastUsed = (item: PersonalAccessToken) => { + const relative = relativeTime(item.last_used_at); + if (!relative || !item.last_used_at) { + return t('settings.accessTokens.never'); + } + const label = + relative.unit === 'date' + ? formatDateOnly(item.last_used_at) + : relative.unit === 'now' + ? t('settings.accessTokens.relative.now') + : t(`settings.accessTokens.relative.${relative.unit}`, { + count: relative.count, + }); + const details = [formatDateTime(item.last_used_at), item.last_used_ip] + .filter(Boolean) + .join(' · '); + return {label}; + }; + + const renderExpiry = (item: PersonalAccessToken) => { + const status = expiryStatus(item.expires_at); + if (status === 'never' || !item.expires_at) { + return t('settings.accessTokens.never'); + } + const date = formatDateOnly(item.expires_at); + if (status === 'ok') return date; + const expired = status === 'expired'; + return ( + + + ); + }; + + const renderRevokeButton = (item: PersonalAccessToken) => ( + + ); + + const renderPrefix = (item: PersonalAccessToken) => ( + + {item.token_prefix}… + + ); + + const renderEmptyState = () => ( +
+ +

+ {t('settings.accessTokens.empty')} +

+ {policy?.enabled && ( +

+ {t('settings.accessTokens.emptyHint')} +

+ )} +
+ ); + + const mobileField = (label: string, value: React.ReactNode) => ( +
+ {label} + + {value} + +
+ ); + + return ( +
+
+
+

+ {t('settings.accessTokens.subtitle')} +

+ {policy?.enabled && ( + + )} +
+ + {policy && !policy.enabled && ( + + + {t('settings.accessTokens.disabledNotice')} + + + )} + {policy?.enabled && limitReached && ( +

+ {t('settings.accessTokens.limitReached', { + count: policy.max_per_user, + })} +

+ )} + {error && ( + + {error} + + )} + +
+ + {loading ? ( + + ) : tokens.length === 0 ? ( + !error && renderEmptyState() + ) : ( + <> + {/* Desktop: table */} + + + + + {t('settings.accessTokens.name')} + + {t('settings.accessTokens.scopes')} + + + {t('settings.accessTokens.resources')} + + + {t('settings.accessTokens.createdAt')} + + + {t('settings.accessTokens.lastUsed')} + + + {t('settings.accessTokens.expires')} + + + + {t('settings.accessTokens.actions')} + + + + + + {tokens.map((item) => ( + + +

+ {item.name} +

+ {renderPrefix(item)} +
+ + + + + {renderRestrictions(item)} + + + {item.created_at + ? formatDateOnly(item.created_at) + : '-'} + + + {renderLastUsed(item)} + + + {renderExpiry(item)} + + + {renderRevokeButton(item)} + +
+ ))} +
+
+
+ + {/* Mobile / tablet: cards */} +
    + {tokens.map((item) => ( +
  • +
    +
    +

    + {item.name} +

    + {renderPrefix(item)} +
    + {renderRevokeButton(item)} +
    + +
    + {mobileField( + t('settings.accessTokens.resources'), + renderRestrictions(item), + )} + {mobileField( + t('settings.accessTokens.createdAt'), + item.created_at ? formatDateOnly(item.created_at) : '-', + )} + {mobileField( + t('settings.accessTokens.lastUsed'), + renderLastUsed(item), + )} + {mobileField( + t('settings.accessTokens.expires'), + renderExpiry(item), + )} +
    +
  • + ))} +
+ + )} +
+ + {policy && ( + setCreateOpen(false)} + scopes={scopes} + policy={policy} + onCreated={handleCreated} + /> + )} + setCreated(null)} + /> + +
+ ); +} diff --git a/frontend/src/settings/accessTokenUtils.test.ts b/frontend/src/settings/accessTokenUtils.test.ts new file mode 100644 index 00000000..f58f95e7 --- /dev/null +++ b/frontend/src/settings/accessTokenUtils.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildResourceFilter, + countLiveTokens, + defaultExpiry, + eligibleFilterFamilies, + expiryOptions, + expiryStatus, + groupScopesByFamily, + isScopeImplied, + isUuid, + NO_EXPIRY, + relativeTime, + restrictionCounts, + scopesToSubmit, + toResourceOptions, +} from './accessTokenUtils'; + +const CATALOG = [ + { name: 'agents:read', description: 'View agents' }, + { name: 'agents:write', description: 'Edit agents' }, + { name: 'agents:keys', description: 'Agent keys' }, + { name: 'sources:read', description: 'View sources' }, + { name: 'analytics:read', description: 'View analytics' }, + { name: 'chat:run', description: 'Ask agents' }, +]; +const FAMILIES = ['agents', 'sources', 'prompts', 'tools', 'workflows']; +const POLICY = { + default_lifetime_days: 90, + max_lifetime_days: 365, + allow_non_expiring: false, +}; +const NOW = Date.parse('2026-09-19T12:00:00Z'); +const DAY = 24 * 60 * 60 * 1000; +const ID_A = '11111111-1111-4111-8111-111111111111'; +const ID_B = '22222222-2222-4222-8222-222222222222'; + +describe('groupScopesByFamily', () => { + it('groups by family in catalog order', () => { + const groups = groupScopesByFamily(CATALOG); + expect(groups.map((g) => g.family)).toEqual([ + 'agents', + 'sources', + 'analytics', + 'chat', + ]); + expect(groups[0].scopes.map((s) => s.name)).toEqual([ + 'agents:read', + 'agents:write', + 'agents:keys', + ]); + }); +}); + +describe('scope implication', () => { + it('write implies read of the same family only', () => { + expect(isScopeImplied('agents:read', ['agents:write'])).toBe(true); + expect(isScopeImplied('sources:read', ['agents:write'])).toBe(false); + expect(isScopeImplied('agents:keys', ['agents:write'])).toBe(false); + expect(isScopeImplied('agents:write', ['agents:write'])).toBe(false); + expect(isScopeImplied('agents:read', ['agents:read'])).toBe(false); + }); + + it('scopesToSubmit drops implied reads and keeps catalog order', () => { + expect( + scopesToSubmit( + ['chat:run', 'agents:write', 'agents:read', 'sources:read'], + CATALOG, + ), + ).toEqual(['agents:write', 'sources:read', 'chat:run']); + }); + + it('scopesToSubmit ignores scopes missing from the catalog', () => { + expect(scopesToSubmit(['bogus:read', 'agents:read'], CATALOG)).toEqual([ + 'agents:read', + ]); + }); +}); + +describe('eligibleFilterFamilies', () => { + it('only offers families that have a selected scope', () => { + expect(eligibleFilterFamilies(['sources:read'], FAMILIES)).toEqual([ + 'sources', + ]); + expect(eligibleFilterFamilies(['analytics:read'], FAMILIES)).toEqual([]); + expect(eligibleFilterFamilies([], FAMILIES)).toEqual([]); + }); + + it('chat:run additionally permits agents and sources', () => { + expect(eligibleFilterFamilies(['chat:run'], FAMILIES)).toEqual([ + 'agents', + 'sources', + ]); + }); + + it('respects the families the server says are filterable', () => { + expect(eligibleFilterFamilies(['chat:run'], ['sources'])).toEqual([ + 'sources', + ]); + }); +}); + +describe('buildResourceFilter', () => { + it('drops empty and ineligible families', () => { + expect( + buildResourceFilter({ agents: [ID_A], sources: [], tools: [ID_B] }, [ + 'agents', + 'sources', + ]), + ).toEqual({ agents: [ID_A] }); + }); + + it('returns undefined when nothing is restricted', () => { + expect(buildResourceFilter({ agents: [] }, ['agents'])).toBeUndefined(); + expect(buildResourceFilter({}, [])).toBeUndefined(); + }); +}); + +describe('restrictionCounts', () => { + it('counts ids per family', () => { + expect( + restrictionCounts({ agents: [ID_A, ID_B], sources: [ID_A] }), + ).toEqual([ + { family: 'agents', count: 2 }, + { family: 'sources', count: 1 }, + ]); + }); + + it('is empty ("all resources") for no filter', () => { + expect(restrictionCounts({})).toEqual([]); + expect(restrictionCounts(null)).toEqual([]); + expect(restrictionCounts({ agents: [] })).toEqual([]); + }); +}); + +describe('expiryOptions / defaultExpiry', () => { + it('offers every preset up to the maximum', () => { + expect(expiryOptions(POLICY)).toEqual([7, 30, 60, 90, 180, 365]); + expect(defaultExpiry(POLICY)).toBe(90); + }); + + it('filters presets above max_lifetime_days', () => { + expect(expiryOptions({ ...POLICY, max_lifetime_days: 90 })).toEqual([ + 7, 30, 60, 90, + ]); + }); + + it('adds a non-preset default in sorted position', () => { + const policy = { ...POLICY, default_lifetime_days: 45 }; + expect(expiryOptions(policy)).toEqual([7, 30, 45, 60, 90, 180, 365]); + expect(defaultExpiry(policy)).toBe(45); + }); + + it('appends "no expiration" only when allowed', () => { + const options = expiryOptions({ ...POLICY, allow_non_expiring: true }); + expect(options[options.length - 1]).toBe(NO_EXPIRY); + expect(expiryOptions(POLICY)).not.toContain(NO_EXPIRY); + }); + + it('falls back to the longest allowed lifetime for an out-of-range default', () => { + const policy = { + ...POLICY, + default_lifetime_days: 400, + max_lifetime_days: 60, + }; + expect(expiryOptions(policy)).toEqual([7, 30, 60]); + expect(defaultExpiry(policy)).toBe(60); + }); + + it('never returns an empty list when the maximum is below every preset', () => { + const policy = { + ...POLICY, + default_lifetime_days: 90, + max_lifetime_days: 3, + }; + expect(expiryOptions(policy)).toEqual([3]); + expect(defaultExpiry(policy)).toBe(3); + }); +}); + +describe('expiryStatus', () => { + const at = (offset: number) => new Date(NOW + offset).toISOString(); + + it('classifies expiry relative to now', () => { + expect(expiryStatus(null, NOW)).toBe('never'); + expect(expiryStatus(at(-1000), NOW)).toBe('expired'); + expect(expiryStatus(at(3 * DAY), NOW)).toBe('expiringSoon'); + expect(expiryStatus(at(7 * DAY), NOW)).toBe('expiringSoon'); + expect(expiryStatus(at(7 * DAY + 60_000), NOW)).toBe('ok'); + }); + + it('does not flag an unparseable date', () => { + expect(expiryStatus('not-a-date', NOW)).toBe('ok'); + }); + + it('countLiveTokens skips expired tokens, like the server cap', () => { + expect( + countLiveTokens( + [ + { expires_at: null }, + { expires_at: at(DAY) }, + { expires_at: at(-DAY) }, + ], + NOW, + ), + ).toBe(2); + }); +}); + +describe('relativeTime', () => { + const ago = (ms: number) => new Date(NOW - ms).toISOString(); + + it('buckets past timestamps', () => { + expect(relativeTime(null, NOW)).toBeNull(); + expect(relativeTime('garbage', NOW)).toBeNull(); + expect(relativeTime(ago(20_000), NOW)).toEqual({ unit: 'now' }); + expect(relativeTime(ago(5 * 60_000), NOW)).toEqual({ + unit: 'minutes', + count: 5, + }); + expect(relativeTime(ago(3 * 3_600_000), NOW)).toEqual({ + unit: 'hours', + count: 3, + }); + expect(relativeTime(ago(2 * DAY), NOW)).toEqual({ unit: 'days', count: 2 }); + expect(relativeTime(ago(45 * DAY), NOW)).toEqual({ unit: 'date' }); + }); + + it('treats clock skew into the future as "now"', () => { + expect(relativeTime(ago(-30_000), NOW)).toEqual({ unit: 'now' }); + }); +}); + +describe('toResourceOptions', () => { + it('accepts only UUID ids', () => { + expect(isUuid(ID_A)).toBe(true); + expect(isUuid('default')).toBe(false); + expect(isUuid(undefined)).toBe(false); + }); + + it('keeps own items with UUID ids, sorted by label', () => { + expect( + toResourceOptions('sources', [ + { id: ID_B, name: 'Zeta' }, + { id: 'default', name: 'Default' }, + { id: ID_A, name: 'Alpha', ownership: 'user' }, + { + id: '33333333-3333-4333-8333-333333333333', + name: 'Team', + ownership: 'team', + }, + ]), + ).toEqual([ + { value: ID_A, label: 'Alpha' }, + { value: ID_B, label: 'Zeta' }, + ]); + }); + + it('skips built-in and team prompts', () => { + expect( + toResourceOptions('prompts', [ + { id: 'default', name: 'default', type: 'public' }, + { id: ID_A, name: 'Mine', type: 'private' }, + { id: ID_B, name: 'Shared', type: 'team' }, + ]), + ).toEqual([{ value: ID_A, label: 'Mine' }]); + }); + + it('reads tools from the {tools: []} envelope and prefers the custom name', () => { + expect( + toResourceOptions('tools', { + success: true, + tools: [ + { + id: ID_A, + name: 'brave', + displayName: 'Brave', + customName: 'My search', + }, + ], + }), + ).toEqual([{ value: ID_A, label: 'My search' }]); + }); + + it('returns nothing for an error body', () => { + expect(toResourceOptions('agents', { success: false })).toEqual([]); + }); +}); diff --git a/frontend/src/settings/accessTokenUtils.ts b/frontend/src/settings/accessTokenUtils.ts new file mode 100644 index 00000000..134d347d --- /dev/null +++ b/frontend/src/settings/accessTokenUtils.ts @@ -0,0 +1,224 @@ +import type { + AccessTokenPolicy, + AccessTokenScope, +} from '../api/services/patService'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Lifetimes (in days) offered in the create form, before policy filtering. */ +export const EXPIRY_PRESETS = [7, 30, 60, 90, 180, 365]; +/** `expires_in_days` value the server reads as "never expires". */ +export const NO_EXPIRY = 0; +/** Tokens expiring within this many days get a warning style. */ +export const EXPIRY_WARNING_DAYS = 7; +/** Families `chat:run` acts on, so they may be restricted alongside it. */ +const CHAT_FAMILIES = ['agents', 'sources']; + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** The server only accepts UUIDs in `resource_filter` (built-ins like `default` are not). */ +export const isUuid = (value: unknown): value is string => + typeof value === 'string' && UUID_REGEX.test(value); + +export const scopeFamily = (scope: string): string => scope.split(':')[0]; +export const scopeAction = (scope: string): string => scope.split(':')[1] ?? ''; + +export interface ScopeGroup { + family: string; + scopes: AccessTokenScope[]; +} + +/** Group the server's scope catalog by family, keeping the server's order. */ +export function groupScopesByFamily(catalog: AccessTokenScope[]): ScopeGroup[] { + const groups: ScopeGroup[] = []; + const byFamily = new Map(); + catalog.forEach((scope) => { + const family = scopeFamily(scope.name); + let group = byFamily.get(family); + if (!group) { + group = { family, scopes: [] }; + byFamily.set(family, group); + groups.push(group); + } + group.scopes.push(scope); + }); + return groups; +} + +/** `x:read` is implied (granted server-side) whenever `x:write` is selected. */ +export function isScopeImplied(scope: string, selected: string[]): boolean { + return ( + scopeAction(scope) === 'read' && + selected.includes(`${scopeFamily(scope)}:write`) + ); +} + +/** + * Scopes to send: the selection minus reads that a selected write already + * implies, in catalog order. The raw selection is kept separately so + * unticking write restores whatever read was before. + */ +export function scopesToSubmit( + selected: string[], + catalog: AccessTokenScope[], +): string[] { + const chosen = new Set(selected); + return catalog + .map((scope) => scope.name) + .filter((name) => chosen.has(name) && !isScopeImplied(name, selected)); +} + +/** Filterable families the selected scopes allow restricting, in policy order. */ +export function eligibleFilterFamilies( + selected: string[], + filterableFamilies: string[], +): string[] { + const families = new Set(selected.map(scopeFamily)); + if (families.has('chat')) CHAT_FAMILIES.forEach((f) => families.add(f)); + return filterableFamilies.filter((family) => families.has(family)); +} + +/** Drop empty and ineligible families; `undefined` when nothing is restricted. */ +export function buildResourceFilter( + selection: Record, + eligibleFamilies: string[], +): Record | undefined { + const out: Record = {}; + eligibleFamilies.forEach((family) => { + const ids = selection[family]; + if (ids && ids.length > 0) out[family] = [...ids]; + }); + return Object.keys(out).length > 0 ? out : undefined; +} + +export interface RestrictionCount { + family: string; + count: number; +} + +/** Per-family counts of a token's `resource_filter`; empty means "all resources". */ +export function restrictionCounts( + resourceFilter: Record | null | undefined, +): RestrictionCount[] { + return Object.entries(resourceFilter ?? {}) + .filter(([, ids]) => Array.isArray(ids) && ids.length > 0) + .map(([family, ids]) => ({ family, count: ids.length })); +} + +type ExpiryPolicy = Pick< + AccessTokenPolicy, + 'default_lifetime_days' | 'max_lifetime_days' | 'allow_non_expiring' +>; + +/** + * Lifetimes (days) to offer: presets within the server maximum, the server + * default when it isn't a preset, and `NO_EXPIRY` last when allowed. + */ +export function expiryOptions(policy: ExpiryPolicy): number[] { + const max = policy.max_lifetime_days; + const days = new Set(EXPIRY_PRESETS.filter((d) => d <= max)); + const fallback = policy.default_lifetime_days; + if (fallback > 0 && fallback <= max) days.add(fallback); + // Never leave the select empty (e.g. max below the smallest preset). + if (days.size === 0 && max > 0) days.add(max); + const options = Array.from(days).sort((a, b) => a - b); + if (policy.allow_non_expiring) options.push(NO_EXPIRY); + return options; +} + +/** The option preselected in the form: the server default, clamped to what's offered. */ +export function defaultExpiry(policy: ExpiryPolicy): number { + const options = expiryOptions(policy); + if (options.includes(policy.default_lifetime_days)) { + return policy.default_lifetime_days; + } + const finite = options.filter((d) => d !== NO_EXPIRY); + return finite.length > 0 ? finite[finite.length - 1] : NO_EXPIRY; +} + +export type ExpiryStatus = 'never' | 'expired' | 'expiringSoon' | 'ok'; + +export function expiryStatus( + expiresAt: string | null | undefined, + now: number = Date.now(), +): ExpiryStatus { + if (!expiresAt) return 'never'; + const at = Date.parse(expiresAt); + if (Number.isNaN(at)) return 'ok'; + if (at <= now) return 'expired'; + if (at - now <= EXPIRY_WARNING_DAYS * DAY_MS) return 'expiringSoon'; + return 'ok'; +} + +/** Tokens that count against `max_per_user`: the server ignores expired ones. */ +export function countLiveTokens( + tokens: { expires_at: string | null }[], + now: number = Date.now(), +): number { + return tokens.filter((tk) => expiryStatus(tk.expires_at, now) !== 'expired') + .length; +} + +export type RelativeTime = + | { unit: 'now' } + | { unit: 'minutes' | 'hours' | 'days'; count: number } + | { unit: 'date' }; + +/** Bucket a past timestamp for display; older than 30 days falls back to a date. */ +export function relativeTime( + value: string | null | undefined, + now: number = Date.now(), +): RelativeTime | null { + if (!value) return null; + const at = Date.parse(value); + if (Number.isNaN(at)) return null; + const minutes = Math.floor(Math.max(0, now - at) / 60_000); + if (minutes < 1) return { unit: 'now' }; + if (minutes < 60) return { unit: 'minutes', count: minutes }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { unit: 'hours', count: hours }; + const days = Math.floor(hours / 24); + if (days <= 30) return { unit: 'days', count: days }; + return { unit: 'date' }; +} + +export interface ResourceOption { + value: string; + label: string; +} + +/** Families the create form can offer a picker for (workflows have no list endpoint). */ +export const PICKER_FAMILIES = ['agents', 'sources', 'prompts', 'tools']; + +/** + * Turn a list-endpoint payload into picker options: only the caller's own + * items (not team-shared or built-in ones) with ids the server will accept. + */ +export function toResourceOptions( + family: string, + payload: unknown, +): ResourceOption[] { + const envelope = payload as { tools?: unknown } | null; + const rows: unknown[] = Array.isArray(payload) + ? payload + : family === 'tools' && Array.isArray(envelope?.tools) + ? envelope.tools + : []; + return (rows as (Record | null)[]) + .filter( + (row): row is Record => + !!row && + isUuid(row.id) && + row.ownership !== 'team' && + !( + family === 'prompts' && + (row.type === 'public' || row.type === 'team') + ), + ) + .map((row) => ({ + value: row.id as string, + label: String(row.customName || row.displayName || row.name || row.id), + })) + .sort((a, b) => a.label.localeCompare(b.label)); +} diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index 1087ce30..f7ccf5f0 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -28,6 +28,7 @@ import CustomModels from './CustomModels'; import Sources from './Sources'; import General from './General'; import Logs from './Logs'; +import PersonalAccessTokens from './PersonalAccessTokens'; import Tools from './Tools'; type HiddenGradientType = 'left' | 'right' | undefined; @@ -47,6 +48,8 @@ export default function Settings() { if (path.includes('/settings/tools')) return t('settings.tools.label'); if (path.includes('/settings/custom-models')) return t('settings.customModels.label'); + if (path.includes('/settings/access-tokens')) + return t('settings.accessTokens.label'); return t('settings.general.label'); }; @@ -58,6 +61,7 @@ export default function Settings() { t('settings.logs.label'), t('settings.tools.label'), t('settings.customModels.label'), + t('settings.accessTokens.label'), ]; const [hiddenGradient, setHiddenGradient] = useState('left'); @@ -93,6 +97,8 @@ export default function Settings() { else if (tab === t('settings.tools.label')) navigate('/settings/tools'); else if (tab === t('settings.customModels.label')) navigate('/settings/custom-models'); + else if (tab === t('settings.accessTokens.label')) + navigate('/settings/access-tokens'); }; React.useEffect(() => { @@ -198,6 +204,7 @@ export default function Settings() { element={} /> } /> + } /> } />
diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index 3b1139e2..fe662e7b 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -6,21 +6,8 @@ export type ChunkType = { metadata: { [key: string]: string }; }; -export type APIKeyData = { - id: string; - name: string; - key: string; - source: string; - prompt_id: string; - chunks: string; -}; - export type LogEventType = - | 'chat' - | 'schedule' - | 'webhook' - | 'workflow' - | 'system'; + 'chat' | 'schedule' | 'webhook' | 'workflow' | 'system'; export type LogData = { id: string;