feat(frontend): access tokens settings tab

Settings > Access Tokens lists a user's personal access tokens and lets them
create and revoke tokens: scopes grouped by family, optional restriction to
specific resources, and an expiry bounded by the server's policy. The secret
is shown once after creation and never stored client side. Removes the
unused legacy api-key endpoints, wrappers, type and locale block.
This commit is contained in:
arc53-machine committed 2026-09-19 23:39:58 +01:00
1 parent 82ec6cba0e
commit 225d9d3065
19 files changed
+2563 -93

No files matched your search

+2 -3
View File
@@ -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',
@@ -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',
);
});
});
+110
View File
@@ -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<string, string[]>;
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<string, string[]>;
/** `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 <T>(response: Response): Promise<T> => {
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<AccessTokenListResponse> =>
parse<AccessTokenListResponse>(
await apiClient.get(endpoints.USER.ACCESS_TOKENS, token),
),
create: async (
payload: CreateAccessTokenPayload,
token: string | null,
): Promise<CreateAccessTokenResponse> =>
parse<CreateAccessTokenResponse>(
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;
-6
View File
@@ -19,12 +19,6 @@ const userService = {
apiClient.get(`${endpoints.USER.DOCS}`, token),
getDocsWithPagination: (query: string, token: string | null): Promise<any> =>
apiClient.get(`${endpoints.USER.DOCS_PAGINATED}?${query}`, token),
getAPIKeys: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.API_KEYS, token),
createAPIKey: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.CREATE_API_KEY, data, token),
deleteAPIKey: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.DELETE_API_KEY, data, token),
getAgent: (id: string, token: string | null): Promise<any> =>
throttledApiClient.get(endpoints.USER.AGENT(id), token),
getAgents: (token: string | null): Promise<any> =>
+59
View File
@@ -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();
});
});
});
+98 -10
View File
@@ -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"
+98 -10
View File
@@ -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"
+98 -10
View File
@@ -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"
+98 -10
View File
@@ -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": "タブを右にスクロール"
+118 -10
View File
@@ -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": "Прокрутить вкладки вправо"
+98 -10
View File
@@ -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": "向右捲動標籤"
+98 -10
View File
@@ -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": "向右滚动标签"
@@ -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 (
<Modal
open={token !== null}
onOpenChange={(o) => !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={
<Button
type="button"
onClick={onClose}
className="rounded-3xl px-6 text-white"
>
{t('settings.accessTokens.created.done')}
</Button>
}
>
<div className="flex flex-col gap-5 px-1">
<div>
<h2 className="text-foreground dark:text-foreground text-xl font-semibold">
{t('settings.accessTokens.created.title')}
</h2>
<p className="text-muted-foreground mt-2 text-sm break-words">
{t('settings.accessTokens.created.subtitle', { name })}
</p>
</div>
<div
role="alert"
className="flex items-start gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-300"
>
<TriangleAlert
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>{t('settings.accessTokens.created.warning')}</p>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-foreground dark:text-foreground text-sm font-semibold">
{t('settings.accessTokens.created.tokenLabel')}
</p>
<div className="bg-muted flex items-center gap-2 rounded-xl py-2 pr-2 pl-4">
<code
data-testid="pat-plaintext"
className="text-foreground dark:text-foreground min-w-0 flex-1 font-mono text-xs break-all select-all sm:text-sm"
>
{token}
</code>
{token && <CopyButton textToCopy={token} showText />}
</div>
</div>
<div className="flex flex-col gap-2">
<p className="text-foreground dark:text-foreground text-sm font-semibold">
{t('settings.accessTokens.created.usageTitle')}
</p>
<p className="text-muted-foreground text-xs">
{t('settings.accessTokens.created.usageHint')}
</p>
{[exportSnippet, curlSnippet].map((snippet, index) => (
<div
key={index}
className="bg-muted flex items-start gap-2 rounded-xl py-2 pr-2 pl-4"
>
<pre className="text-foreground dark:text-foreground min-w-0 flex-1 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
{snippet}
</pre>
<CopyButton textToCopy={snippet} />
</div>
))}
</div>
</div>
</Modal>
);
}
@@ -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<Response>
> = {
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<string[]>([]);
const [expiry, setExpiry] = React.useState<number>(() =>
defaultExpiry(policy),
);
const [restrict, setRestrict] = React.useState(false);
const [resourceSelection, setResourceSelection] = React.useState<
Record<string, string[]>
>({});
const [resources, setResources] = React.useState<
Record<string, ResourceState>
>({});
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<Modal
open={open}
onOpenChange={(o) => !o && handleClose()}
hideTitle
title={t('settings.accessTokens.create.title')}
size="lg"
mobileVariant="sheet"
isPerformingTask={submitting}
contentClassName="max-h-[65vh]"
footer={
<>
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={submitting}
className="rounded-3xl px-6"
>
{t('settings.accessTokens.create.cancel')}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-3xl px-6 text-white"
>
{submitting ? (
<span className="flex items-center gap-2">
<Spinner size="small" />
{t('settings.accessTokens.create.submitting')}
</span>
) : (
t('settings.accessTokens.create.submit')
)}
</Button>
</>
}
>
<form
className="flex flex-col gap-6 px-1"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div>
<h2 className="text-foreground dark:text-foreground text-xl font-semibold">
{t('settings.accessTokens.create.title')}
</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t('settings.accessTokens.create.subtitle')}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="pat-name">
{t('settings.accessTokens.create.name')}
<span className="text-red-500">*</span>
</Label>
<Input
id="pat-name"
type="text"
value={name}
maxLength={MAX_NAME_LENGTH}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder={t('settings.accessTokens.create.namePlaceholder')}
className="rounded-xl"
autoComplete="off"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="pat-expiry">
{t('settings.accessTokens.create.expiration')}
</Label>
<Select
value={String(expiry)}
onValueChange={(value) => setExpiry(Number(value))}
>
<SelectTrigger
id="pat-expiry"
className="w-full rounded-xl px-4 py-2"
size="lg"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{expiryChoices.map((days) => (
<SelectItem key={days} value={String(days)}>
{expiryLabel(days)}
</SelectItem>
))}
</SelectContent>
</Select>
<p
className={`text-xs ${
expiry === NO_EXPIRY
? 'text-amber-700 dark:text-amber-400'
: 'text-muted-foreground'
}`}
>
{expiry === NO_EXPIRY
? t('settings.accessTokens.create.noExpirationHint')
: t('settings.accessTokens.create.expiresOn', {
date: formatDateOnly(
new Date(Date.now() + expiry * DAY_MS).toISOString(),
),
})}
</p>
</div>
</div>
<fieldset className="flex flex-col gap-3">
<legend className="text-foreground dark:text-foreground text-sm font-semibold">
{t('settings.accessTokens.create.scopes')}
<span className="text-red-500">*</span>
</legend>
<p className="text-muted-foreground text-xs">
{t('settings.accessTokens.create.scopesHint')}
</p>
<div className="border-border dark:border-border divide-border flex flex-col divide-y rounded-xl border">
{scopeGroups.map((group) => (
<div
key={group.family}
className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:gap-4"
>
<p className="text-foreground dark:text-foreground w-32 shrink-0 text-sm font-medium capitalize">
{familyLabel(group.family)}
</p>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
{group.scopes.map((scope) => {
const implied = isScopeImplied(scope.name, selectedScopes);
const checked =
implied || selectedScopes.includes(scope.name);
const id = `pat-scope-${scope.name}`;
return (
<label
key={scope.name}
htmlFor={id}
title={
implied
? t('settings.accessTokens.create.impliedByWrite')
: undefined
}
className={`flex items-start gap-3 ${
implied
? 'cursor-not-allowed opacity-70'
: 'cursor-pointer'
}`}
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={implied}
onChange={() => toggleScope(scope.name)}
className="accent-primary mt-0.5 size-4 shrink-0 rounded-sm border-gray-300 bg-transparent"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<code className="text-foreground dark:text-foreground font-mono text-xs font-medium">
{scope.name}
</code>
<span className="text-muted-foreground text-xs leading-relaxed">
{scope.description}
</span>
</span>
</label>
);
})}
</div>
</div>
))}
</div>
</fieldset>
{policy.filterable_families.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="pat-restrict" className="text-sm font-semibold">
{t('settings.accessTokens.create.restrict')}
</Label>
<p className="text-muted-foreground text-xs">
{t('settings.accessTokens.create.restrictHint')}
</p>
</div>
<Switch
id="pat-restrict"
checked={restrict}
onCheckedChange={setRestrict}
/>
</div>
{restrict && pickerFamilies.length === 0 && (
<p className="text-muted-foreground bg-muted rounded-lg px-4 py-2 text-xs">
{t('settings.accessTokens.create.restrictNoFamilies')}
</p>
)}
{restrict &&
pickerFamilies.map((family) => {
const state = resources[family];
return (
<div key={family} className="flex flex-col gap-1.5">
<Label className="capitalize">{familyLabel(family)}</Label>
{!state || state.status === 'loading' ? (
<div className="flex h-10 items-center">
<Spinner size="small" />
</div>
) : state.status === 'error' ? (
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="text-destructive">
{t('settings.accessTokens.create.resourcesError')}
</span>
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs"
onClick={() => loadResources(family)}
>
{t('settings.accessTokens.create.retry')}
</Button>
</div>
) : (
<MultiSelect
options={state.options}
selected={resourceSelection[family] ?? []}
onChange={(ids) =>
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"
/>
)}
</div>
);
})}
</div>
)}
{error && (
<div
role="alert"
className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
>
{error}
</div>
)}
</form>
</Modal>
);
}
@@ -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 (
<div className="flex flex-wrap items-center gap-1">
{visible.map((scope) => (
<span
key={scope}
className="bg-muted text-foreground dark:text-foreground rounded-full px-2 py-0.5 font-mono text-[11px] leading-4 whitespace-nowrap"
>
{scope}
</span>
))}
{scopes.length > VISIBLE_SCOPES && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
title={expanded ? undefined : scopes.slice(VISIBLE_SCOPES).join(', ')}
className="text-primary hover:bg-primary/10 cursor-pointer rounded-full px-2 py-0.5 text-[11px] leading-4 font-medium whitespace-nowrap"
>
{expanded
? t('settings.accessTokens.showLess')
: t('settings.accessTokens.moreScopes', { count: hidden })}
</button>
)}
</div>
);
}
export default function PersonalAccessTokens() {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
const [tokens, setTokens] = React.useState<PersonalAccessToken[]>([]);
const [scopes, setScopes] = React.useState<AccessTokenScope[]>([]);
const [policy, setPolicy] = React.useState<AccessTokenPolicy | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
// The plaintext secret lives only here, and only until the modal closes.
const [created, setCreated] =
React.useState<CreateAccessTokenResponse | null>(null);
const [revokeState, setRevokeState] = React.useState<ActiveState>('INACTIVE');
const [tokenToRevoke, setTokenToRevoke] =
React.useState<PersonalAccessToken | null>(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 <span title={details}>{label}</span>;
};
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 (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${
expired
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
}`}
title={date}
>
<TriangleAlert className="size-3 shrink-0" aria-hidden="true" />
{expired
? t('settings.accessTokens.expired', { date })
: t('settings.accessTokens.expiresSoon', { date })}
</span>
);
};
const renderRevokeButton = (item: PersonalAccessToken) => (
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive border-destructive/40 hover:bg-destructive/10 rounded-full px-4"
onClick={() => requestRevoke(item)}
aria-label={t('settings.accessTokens.revokeAria', { name: item.name })}
>
{t('settings.accessTokens.revoke')}
</Button>
);
const renderPrefix = (item: PersonalAccessToken) => (
<code className="text-muted-foreground font-mono text-xs whitespace-nowrap">
{item.token_prefix}…
</code>
);
const renderEmptyState = () => (
<div className="flex w-full flex-col items-center justify-center py-12">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt=""
className="mx-auto mb-6 h-32 w-32"
/>
<p className="text-center text-lg text-gray-500 dark:text-gray-400">
{t('settings.accessTokens.empty')}
</p>
{policy?.enabled && (
<p className="text-muted-foreground mt-2 max-w-md text-center text-sm">
{t('settings.accessTokens.emptyHint')}
</p>
)}
</div>
);
const mobileField = (label: string, value: React.ReactNode) => (
<div className="flex items-start justify-between gap-4 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-foreground dark:text-foreground min-w-0 text-right">
{value}
</span>
</div>
);
return (
<div className="mt-8">
<div className="relative flex flex-col">
<div className="my-3 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground max-w-2xl text-sm leading-6">
{t('settings.accessTokens.subtitle')}
</p>
{policy?.enabled && (
<Button
type="button"
className="h-11 min-w-[108px] shrink-0 rounded-full whitespace-normal text-white"
onClick={() => setCreateOpen(true)}
disabled={limitReached}
title={
limitReached
? t('settings.accessTokens.limitReached', {
count: policy.max_per_user,
})
: undefined
}
>
{t('settings.accessTokens.createToken')}
</Button>
)}
</div>
{policy && !policy.enabled && (
<Alert className="mt-2">
<AlertDescription>
{t('settings.accessTokens.disabledNotice')}
</AlertDescription>
</Alert>
)}
{policy?.enabled && limitReached && (
<p className="text-muted-foreground mt-1 text-xs">
{t('settings.accessTokens.limitReached', {
count: policy.max_per_user,
})}
</p>
)}
{error && (
<Alert variant="destructive" className="mt-2">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="border-border dark:border-border mt-5 mb-8 border-b" />
{loading ? (
<SkeletonLoader component="default" />
) : tokens.length === 0 ? (
!error && renderEmptyState()
) : (
<>
{/* Desktop: table */}
<TableContainer className="hidden lg:block">
<Table>
<TableHead>
<TableRow>
<TableHeader>{t('settings.accessTokens.name')}</TableHeader>
<TableHeader>
{t('settings.accessTokens.scopes')}
</TableHeader>
<TableHeader>
{t('settings.accessTokens.resources')}
</TableHeader>
<TableHeader>
{t('settings.accessTokens.createdAt')}
</TableHeader>
<TableHeader>
{t('settings.accessTokens.lastUsed')}
</TableHeader>
<TableHeader>
{t('settings.accessTokens.expires')}
</TableHeader>
<TableHeader align="right">
<span className="sr-only">
{t('settings.accessTokens.actions')}
</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{tokens.map((item) => (
<TableRow key={item.id}>
<TableCell className="max-w-[220px]">
<p
className="text-foreground dark:text-foreground truncate font-medium"
title={item.name}
>
{item.name}
</p>
{renderPrefix(item)}
</TableCell>
<TableCell className="max-w-[280px]">
<ScopeChips scopes={item.scopes} />
</TableCell>
<TableCell className="text-muted-foreground">
{renderRestrictions(item)}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{item.created_at
? formatDateOnly(item.created_at)
: '-'}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{renderLastUsed(item)}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{renderExpiry(item)}
</TableCell>
<TableCell align="right">
{renderRevokeButton(item)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Mobile / tablet: cards */}
<ul className="flex flex-col gap-4 lg:hidden">
{tokens.map((item) => (
<li
key={item.id}
className="bg-muted/60 flex flex-col gap-3 rounded-2xl p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p
className="text-foreground dark:text-foreground truncate text-sm font-semibold"
title={item.name}
>
{item.name}
</p>
{renderPrefix(item)}
</div>
{renderRevokeButton(item)}
</div>
<ScopeChips scopes={item.scopes} />
<div className="flex flex-col gap-1.5">
{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),
)}
</div>
</li>
))}
</ul>
</>
)}
</div>
{policy && (
<CreateAccessTokenModal
open={createOpen}
onClose={() => setCreateOpen(false)}
scopes={scopes}
policy={policy}
onCreated={handleCreated}
/>
)}
<AccessTokenCreatedModal
token={created?.token ?? null}
name={created?.personal_access_token.name ?? ''}
onClose={() => setCreated(null)}
/>
<ConfirmationModal
message={t('settings.accessTokens.revokeWarning', {
name: tokenToRevoke?.name ?? '',
})}
modalState={revokeState}
setModalState={setRevokeState}
handleSubmit={confirmRevoke}
submitLabel={t('settings.accessTokens.revoke')}
variant="danger"
/>
</div>
);
}
@@ -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([]);
});
});
+224
View File
@@ -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<string, ScopeGroup>();
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<string, string[]>,
eligibleFamilies: string[],
): Record<string, string[]> | undefined {
const out: Record<string, string[]> = {};
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<string, string[]> | 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<string, unknown> | null)[])
.filter(
(row): row is Record<string, unknown> =>
!!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));
}
+7
View File
@@ -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<HiddenGradientType>('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={<Navigate to="/settings/tools" replace />}
/>
<Route path="custom-models" element={<CustomModels />} />
<Route path="access-tokens" element={<PersonalAccessTokens />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
</div>
+1 -14
View File
@@ -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;