diff --git a/client/src/components/Settings/ApiKeysSection.test.tsx b/client/src/components/Settings/ApiKeysSection.test.tsx index cc1067bc9..c3e1b6cf6 100644 --- a/client/src/components/Settings/ApiKeysSection.test.tsx +++ b/client/src/components/Settings/ApiKeysSection.test.tsx @@ -1,5 +1,5 @@ -// FE-COMP-APIKEYS-001 to FE-COMP-APIKEYS-010 -import { render, screen, waitFor } from '../../../tests/helpers/render'; +// FE-COMP-APIKEYS-001 to FE-COMP-APIKEYS-020 +import { render, screen, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -21,7 +21,8 @@ beforeAll(() => { }); beforeEach(() => { - clipboardWriteText.mockClear(); + clipboardWriteText.mockReset(); + clipboardWriteText.mockResolvedValue(undefined); resetAllStores(); vi.clearAllMocks(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); @@ -38,6 +39,39 @@ function renderSection() { ); } +/** userEvent.setup() installs its own clipboard stub, so the spy goes back on afterwards. */ +function restoreClipboardSpy() { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: clipboardWriteText }, + configurable: true, + writable: true, + }); +} + +function createdResponse(extra: Record = {}) { + return HttpResponse.json({ + token: { + id: 1, + name: 'Dawarich', + raw_token: 'trek_thefullsecretvalue', + token_prefix: 'trek_thefull', + created_at: '2026-08-27T10:00:00Z', + ...extra, + }, + }); +} + +function keyRow(overrides: Record = {}) { + return { + id: 7, + name: 'Dawarich', + token_prefix: 'trek_abcdefg', + created_at: '2026-08-01T10:00:00Z', + last_used_at: null, + ...overrides, + }; +} + describe('ApiKeysSection', () => { it('FE-COMP-APIKEYS-001: renders without the MCP addon, because an API key does not need it', async () => { renderSection(); @@ -62,24 +96,10 @@ describe('ApiKeysSection', () => { }); it('FE-COMP-APIKEYS-003: lists existing keys by prefix, never the key itself', async () => { - server.use( - http.get('/api/auth/api-tokens', () => - HttpResponse.json({ - tokens: [ - { - id: 7, - name: 'Dawarich', - token_prefix: 'trek_abcdefg', - created_at: '2026-08-01T10:00:00Z', - last_used_at: null, - }, - ], - }), - ), - ); + server.use(http.get('/api/auth/api-tokens', () => HttpResponse.json({ tokens: [keyRow()] }))); renderSection(); expect(await screen.findByText('Dawarich')).toBeInTheDocument(); - expect(screen.getByText(/trek_abcdefg\.\.\./)).toBeInTheDocument(); + expect(screen.getByText('trek_abcdefg…')).toBeInTheDocument(); }); it('FE-COMP-APIKEYS-004: shows the empty state when there is nothing to list', async () => { @@ -88,19 +108,7 @@ describe('ApiKeysSection', () => { }); it('FE-COMP-APIKEYS-005: creates a key and shows it once, with the warning', async () => { - server.use( - http.post('/api/auth/api-tokens', () => - HttpResponse.json({ - token: { - id: 1, - name: 'Dawarich', - raw_token: 'trek_thefullsecretvalue', - token_prefix: 'trek_thefull', - created_at: '2026-08-27T10:00:00Z', - }, - }), - ), - ); + server.use(http.post('/api/auth/api-tokens', () => createdResponse())); const user = userEvent.setup(); renderSection(); @@ -113,19 +121,7 @@ describe('ApiKeysSection', () => { }); it('FE-COMP-APIKEYS-006: drops the raw key from the DOM once the modal is closed', async () => { - server.use( - http.post('/api/auth/api-tokens', () => - HttpResponse.json({ - token: { - id: 1, - name: 'Dawarich', - raw_token: 'trek_thefullsecretvalue', - token_prefix: 'trek_thefull', - created_at: '2026-08-27T10:00:00Z', - }, - }), - ), - ); + server.use(http.post('/api/auth/api-tokens', () => createdResponse())); const user = userEvent.setup(); renderSection(); @@ -137,7 +133,7 @@ describe('ApiKeysSection', () => { await waitFor(() => expect(screen.queryByText('trek_thefullsecretvalue')).not.toBeInTheDocument()); // The list keeps only the prefix, which is what the server stores alongside the hash. - expect(screen.getByText(/trek_thefull\.\.\./)).toBeInTheDocument(); + expect(screen.getByText('trek_thefull…')).toBeInTheDocument(); }); it('FE-COMP-APIKEYS-007: refuses to create a key without a name', async () => { @@ -150,13 +146,7 @@ describe('ApiKeysSection', () => { it('FE-COMP-APIKEYS-008: asks before deleting, and only deletes on confirm', async () => { let deleted = 0; server.use( - http.get('/api/auth/api-tokens', () => - HttpResponse.json({ - tokens: [ - { id: 7, name: 'Dawarich', token_prefix: 'trek_abc', created_at: '2026-08-01T10:00:00Z', last_used_at: null }, - ], - }), - ), + http.get('/api/auth/api-tokens', () => HttpResponse.json({ tokens: [keyRow({ token_prefix: 'trek_abc' })] })), http.delete('/api/auth/api-tokens/7', () => { deleted += 1; return HttpResponse.json({ success: true }); @@ -192,37 +182,175 @@ describe('ApiKeysSection', () => { }); it('FE-COMP-APIKEYS-010: copies the new key to the clipboard on request', async () => { + server.use(http.post('/api/auth/api-tokens', () => createdResponse())); + const user = userEvent.setup(); + restoreClipboardSpy(); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Create key/ })); + await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich'); + await user.click(screen.getByRole('button', { name: /^Create$/ })); + const secret = await screen.findByText('trek_thefullsecretvalue'); + // The section's endpoint row has a copy button of its own; this one sits beside the key. + await user.click(within(secret.parentElement!).getByTitle('Copy')); + + expect(clipboardWriteText).toHaveBeenCalledWith('trek_thefullsecretvalue'); + // And the button confirms it to the reader, which is the only feedback there is. + expect(await within(secret.parentElement!).findByText('Copied')).toBeInTheDocument(); + }); + + it('FE-COMP-APIKEYS-011: Enter with nothing selected does not mint a key that reads everything', async () => { + const posts: unknown[] = []; server.use( - http.post('/api/auth/api-tokens', () => - HttpResponse.json({ - token: { - id: 1, - name: 'Dawarich', - raw_token: 'trek_thefullsecretvalue', - token_prefix: 'trek_thefull', - created_at: '2026-08-27T10:00:00Z', - }, - }), - ), + http.post('/api/auth/api-tokens', async ({ request }) => { + posts.push(await request.json()); + return createdResponse(); + }), ); const user = userEvent.setup(); - // userEvent.setup() installs its own clipboard stub, so the spy goes back on - // afterwards — otherwise this asserts against a stub the component never reaches. - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: clipboardWriteText }, - configurable: true, - writable: true, - }); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Create key/ })); + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + expect(screen.getByText(/Pick at least one area/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Create$/ })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich{Enter}'); + + // An empty list would be dropped from the request, and the server stores + // "no list" as a key with full access, so nothing may go out at all. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(posts).toEqual([]); + }); + + it('FE-COMP-APIKEYS-012: sends only the areas left switched on', async () => { + const posts: Array<{ name?: string; scopes?: string[] }> = []; + server.use( + http.post('/api/auth/api-tokens', async ({ request }) => { + posts.push((await request.json()) as { name?: string; scopes?: string[] }); + return createdResponse({ scope_mode: 'limited', scopes: ['trips', 'days'] }); + }), + ); + const user = userEvent.setup(); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Create key/ })); + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + const trips = screen.getByRole('checkbox', { name: 'Trips' }); + expect(trips).toHaveAttribute('aria-checked', 'false'); + await user.click(trips); + await user.click(screen.getByRole('checkbox', { name: 'Days' })); + expect(trips).toHaveAttribute('aria-checked', 'true'); + + await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich'); + await user.click(screen.getByRole('button', { name: /^Create$/ })); + + await waitFor(() => expect(posts).toHaveLength(1)); + expect(posts[0]).toEqual({ name: 'Dawarich', scopes: ['trips', 'days'] }); + }); + + it('FE-COMP-APIKEYS-013: sends no list at all when every area stays on', async () => { + const posts: Array> = []; + server.use( + http.post('/api/auth/api-tokens', async ({ request }) => { + posts.push((await request.json()) as Record); + return createdResponse(); + }), + ); + const user = userEvent.setup(); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Create key/ })); + // Off and back on again: the result is "everything", not a limited key that lists it all. + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + await user.click(screen.getByRole('button', { name: 'Select all' })); + await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich'); + await user.click(screen.getByRole('button', { name: /^Create$/ })); + + await waitFor(() => expect(posts).toHaveLength(1)); + expect(posts[0]).toEqual({ name: 'Dawarich' }); + }); + + it('FE-COMP-APIKEYS-014: a failed load says so instead of claiming there are no keys', async () => { + server.use(http.get('/api/auth/api-tokens', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); + renderSection(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/Could not load your keys/); + expect(screen.queryByText(/No keys yet/)).not.toBeInTheDocument(); + }); + + it('FE-COMP-APIKEYS-015: shows what a narrowed key may read, and that it was never used', async () => { + server.use( + http.get('/api/auth/api-tokens', () => + HttpResponse.json({ tokens: [keyRow({ scope_mode: 'limited', scopes: ['places', 'notes'] })] }), + ), + ); + renderSection(); + + expect(await screen.findByText('Places')).toBeInTheDocument(); + expect(screen.getByText('Day notes')).toBeInTheDocument(); + expect(screen.queryByText('Everything')).not.toBeInTheDocument(); + expect(screen.getByText(/never used/)).toBeInTheDocument(); + }); + + it('FE-COMP-APIKEYS-016: labels a key minted before scopes existed as reading everything', async () => { + server.use( + http.get('/api/auth/api-tokens', () => + HttpResponse.json({ tokens: [keyRow({ last_used_at: '2026-09-01 08:30:00' })] }), + ), + ); + renderSection(); + + expect(await screen.findByText('Everything')).toBeInTheDocument(); + // SQLite's "YYYY-MM-DD HH:MM:SS" has to come out as a date, not "Invalid Date". + expect(screen.getByText(/last used/)).not.toHaveTextContent(/Invalid Date/); + expect(screen.getByText(/last used/)).toHaveTextContent(new Date('2026-09-01T08:30:00Z').toLocaleDateString('en')); + }); + + it('FE-COMP-APIKEYS-017: stops offering a new key once the account holds the most it can', async () => { + const tokens = Array.from({ length: 10 }, (_, i) => keyRow({ id: i + 1, name: `Key ${i + 1}` })); + server.use(http.get('/api/auth/api-tokens', () => HttpResponse.json({ tokens }))); + renderSection(); + + expect(await screen.findByText('Key 10')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create key/ })).toBeDisabled(); + expect(screen.getByText(/You have 10 keys/)).toBeInTheDocument(); + }); + + it('FE-COMP-APIKEYS-018: copies the endpoint the key is meant for', async () => { + const user = userEvent.setup(); + restoreClipboardSpy(); + renderSection(); + + const endpoint = await screen.findByText(`${window.location.origin}/api/v1`); + await user.click(within(endpoint.parentElement!).getByRole('button', { name: 'Copy' })); + + expect(clipboardWriteText).toHaveBeenCalledWith(`${window.location.origin}/api/v1`); + }); + + it('FE-COMP-APIKEYS-019: tells the reader when the clipboard refuses, instead of staying silent', async () => { + clipboardWriteText.mockRejectedValue(new Error('denied')); + const user = userEvent.setup(); + restoreClipboardSpy(); + renderSection(); + + const endpoint = await screen.findByText(`${window.location.origin}/api/v1`); + await user.click(within(endpoint.parentElement!).getByRole('button', { name: 'Copy' })); + + expect(await screen.findByText(/Could not copy/)).toBeInTheDocument(); + }); + + it('FE-COMP-APIKEYS-020: keeps the new key on screen through Escape, so it cannot be lost by accident', async () => { + server.use(http.post('/api/auth/api-tokens', () => createdResponse())); + const user = userEvent.setup(); renderSection(); await user.click(await screen.findByRole('button', { name: /Create key/ })); await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich'); await user.click(screen.getByRole('button', { name: /^Create$/ })); await screen.findByText('trek_thefullsecretvalue'); - await user.click(screen.getByTitle('Copy')); - expect(clipboardWriteText).toHaveBeenCalledWith('trek_thefullsecretvalue'); - // And the button confirms it to the reader, which is the only feedback there is. - expect(await screen.findByTitle('Copy')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(screen.getByText('trek_thefullsecretvalue')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Settings/ApiKeysSection.tsx b/client/src/components/Settings/ApiKeysSection.tsx index 4c0659507..fb0ea005f 100644 --- a/client/src/components/Settings/ApiKeysSection.tsx +++ b/client/src/components/Settings/ApiKeysSection.tsx @@ -1,7 +1,12 @@ -import React, { useEffect, useState } from 'react' -import { KeyRound, Plus, Trash2, Copy, Check } from 'lucide-react' +import React, { useEffect, useRef, useState } from 'react' +import { + KeyRound, Plus, Trash2, Copy, Check, AlertTriangle, Briefcase, CalendarDays, MapPin, + StickyNote, Ticket, Hotel, Users, Star, BarChart3, type LucideIcon, +} from 'lucide-react' import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared' import Section from './Section' +import Modal from '../shared/Modal' +import ConfirmDialog from '../shared/ConfirmDialog' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { authApi } from '../../api/client' @@ -30,17 +35,61 @@ interface ApiKey { scopes?: PublicApiScope[] } +/** The server refuses an eleventh key (`TokenService.createToken`). */ +const MAX_KEYS = 10 + +/** The glyph each area already wears elsewhere in the app, so the list reads like the planner. */ +const SCOPE_ICONS: Record = { + trips: Briefcase, + days: CalendarDays, + places: MapPin, + notes: StickyNote, + reservations: Ticket, + accommodations: Hotel, + travellers: Users, + 'bucket-list': Star, + stats: BarChart3, +} + +/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */ +function formatStamp(ts: string, locale: string): string { + const iso = ts.includes('T') ? ts : ts.replace(' ', 'T') + const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`) + return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale) +} + +/** + * The usage hint with its header names and path set as code. + * + * Every locale quotes the two headers the same way, so they can be picked out + * of the sentence instead of cutting it into fragments a translator would have + * to reassemble. A translation that drops the quotes simply stays plain text. + */ +function withCode(text: string): React.ReactNode[] { + return text.split(/("[^"]+"|\/api\/v1)/).map((part, i) => { + if (i % 2 === 0) return part + return ( + + {part.replace(/^"|"$/g, '')} + + ) + }) +} + export default function ApiKeysSection(): React.ReactElement { const { t, locale } = useTranslation() const toast = useToast() const [keys, setKeys] = useState([]) + /** A failed load must not read as "you have no keys": that sends people off minting duplicates. */ + const [loadState, setLoadState] = useState<'loading' | 'ready' | 'failed'>('loading') const [modalOpen, setModalOpen] = useState(false) const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) /** The raw key, held only until the modal closes — the server keeps a hash. */ const [created, setCreated] = useState(null) const [deleteId, setDeleteId] = useState(null) - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useState<'key' | 'endpoint' | null>(null) + const copiedTimer = useRef | undefined>(undefined) /** * What the new key may read. Starts as everything, because that is what a key * did before this existed and what most integrations want — narrowing is a @@ -48,19 +97,38 @@ export default function ApiKeysSection(): React.ReactElement { */ const [newScopes, setNewScopes] = useState>(new Set(PUBLIC_API_SCOPES)) + const endpoint = `${window.location.origin}/api/v1` + const atLimit = keys.length >= MAX_KEYS + const allSelected = newScopes.size === PUBLIC_API_SCOPES.length + const canCreate = !!newName.trim() && !creating && newScopes.size > 0 + useEffect(() => { - authApi.apiKeys.list().then(d => setKeys(d.tokens || [])).catch(() => {}) + let cancelled = false + authApi.apiKeys.list() + .then(d => { + if (cancelled) return + setKeys(d.tokens || []) + setLoadState('ready') + }) + .catch(() => { if (!cancelled) setLoadState('failed') }) + return () => { + cancelled = true + clearTimeout(copiedTimer.current) + } }, []) const handleCreate = async () => { - if (!newName.trim() || creating) return + // Enter in the name field arrives here past the button's disabled state, and + // an empty selection must not fall through to the "no narrowing" branch + // below, which would mint a key that reads everything. + if (!canCreate) return setCreating(true) try { // All of them selected means "no narrowing", which is what the server // stores as `all` — sending the full list would record it as a limited key // that happens to allow everything, and a section added in a later version // would then be refused for a key nobody meant to restrict. - const narrowed = newScopes.size < PUBLIC_API_SCOPES.length ? [...newScopes] : undefined + const narrowed = allSelected ? undefined : [...newScopes] const d = await authApi.apiKeys.create(newName.trim(), narrowed) setCreated(d.token.raw_token) setKeys(prev => [ @@ -90,23 +158,38 @@ export default function ApiKeysSection(): React.ReactElement { toast.success(t('settings.apiKeys.deleted')) } catch { toast.error(t('settings.apiKeys.deleteFailed')) - } finally { - setDeleteId(null) } } - const handleCopy = () => { - if (!created) return - navigator.clipboard.writeText(created) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + const handleCopy = async (text: string, what: 'key' | 'endpoint') => { + try { + // Absent on a plain-http origin, which is exactly where a self-run + // instance on the LAN tends to live. + if (!navigator.clipboard) throw new Error('clipboard unavailable') + await navigator.clipboard.writeText(text) + setCopied(what) + clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(null), 2000) + } catch { + toast.error(t('settings.apiKeys.copyFailed')) + } + } + + const resetForm = () => { + setCreated(null) + setCopied(null) + setNewName('') + setNewScopes(new Set(PUBLIC_API_SCOPES)) + } + + const openModal = () => { + resetForm() + setModalOpen(true) } const closeModal = () => { setModalOpen(false) - setCreated(null) - setNewName('') - setNewScopes(new Set(PUBLIC_API_SCOPES)) + resetForm() } const toggleScope = (scope: PublicApiScope) => { @@ -121,148 +204,227 @@ export default function ApiKeysSection(): React.ReactElement { return ( <>
-

{t('settings.apiKeys.description')}

- -
-
- {keys.length === 0 ? ( -

- {t('settings.apiKeys.empty')} + {atLimit && ( +

{t('settings.apiKeys.limitReached', { max: MAX_KEYS })}

+ )} + + {loadState === 'failed' && ( +

+ {t('settings.apiKeys.loadFailed')}

- ) : ( -
- {keys.map((key, i) => ( -
-
-

{key.name}

-

- {key.token_prefix}... - {t('settings.apiKeys.createdAt')} {new Date(key.created_at).toLocaleDateString(locale)} - {key.last_used_at && ( - · {t('settings.apiKeys.usedAt')} {new Date(key.last_used_at).toLocaleDateString(locale)} - )} -

- {/* What the key may read. Shown on the row rather than behind a - detail view: the whole reason to narrow a key is to be able - to see later that you did. */} -

- {key.scope_mode === 'limited' && key.scopes - ? key.scopes.map(scope => t(`settings.apiScopes.${scope}`)).join(', ') - : t('settings.apiScopes.all')} -

-
- -
- ))} + )} + + {loadState === 'ready' && keys.length === 0 && ( +
+ + + +

{t('settings.apiKeys.empty')}

)} -

{t('settings.apiKeys.docsHint')}

+ {keys.length > 0 && ( +
    + {keys.map(key => ( + setDeleteId(key.id)} /> + ))} +
+ )} + +
+ {t('settings.apiKeys.endpoint')} +
+ + {endpoint} + + +
+

{withCode(t('settings.apiKeys.docsHint'))}

+
- {modalOpen && ( -
{ if (e.target === e.currentTarget && !created) closeModal() }}> -
- {!created ? ( - <> -

{t('settings.apiKeys.modal.createTitle')}

-
- - setNewName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleCreate()} - placeholder={t('settings.apiKeys.modal.namePlaceholder')} - className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content" - autoFocus /> -

{t('settings.apiKeys.modal.nameHint')}

-
- -
- - {t('settings.apiScopes.title')} - -

{t('settings.apiScopes.hint')}

-
- {PUBLIC_API_SCOPES.map(scope => ( - - ))} -
-
-
- - -
- - ) : ( - <> -

{t('settings.apiKeys.modal.createdTitle')}

-
- ⚠ -

{t('settings.apiKeys.modal.createdWarning')}

-
-
-
-                    {created}
-                  
- -
-
- -
- - )} + {} : closeModal} + hideCloseButton={!!created} + size="lg" + title={created ? t('settings.apiKeys.modal.createdTitle') : t('settings.apiKeys.modal.createTitle')} + footer={created ? ( +
+
-
- )} - - {deleteId !== null && ( -
{ if (e.target === e.currentTarget) setDeleteId(null) }}> -
-

{t('settings.apiKeys.deleteTitle')}

-

{t('settings.apiKeys.deleteMessage')}

-
- - + +
+ )} + > + {created ? ( +
+
+ +

{t('settings.apiKeys.modal.createdWarning')}

+
+
+ {created} +
-
- )} + ) : ( +
+
+ + setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreate() }} + placeholder={t('settings.apiKeys.modal.namePlaceholder')} + maxLength={100} + className="w-full rounded-lg border px-3 py-2.5 text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content" + autoFocus /> +

{t('settings.apiKeys.modal.nameHint')}

+
+ +
+
+ + {t('settings.apiScopes.title')} + + +
+

{t('settings.apiScopes.hint')}

+
+ {PUBLIC_API_SCOPES.map(scope => ( + toggleScope(scope)} /> + ))} +
+ {newScopes.size === 0 && ( +

{t('settings.apiScopes.noneSelected')}

+ )} +
+
+ )} + + + setDeleteId(null)} + onConfirm={() => { if (deleteId !== null) handleDelete(deleteId) }} + title={t('settings.apiKeys.deleteTitle')} + message={t('settings.apiKeys.deleteMessage')} + confirmLabel={t('settings.apiKeys.deleteTitle')} + /> ) } + +function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement { + const { t } = useTranslation() + const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined + return ( +
  • + + + +
    +
    + {apiKey.name} + + {apiKey.token_prefix}… + +
    +

    + {t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)} + {' · '} + {apiKey.last_used_at + ? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}` + : t('settings.apiKeys.neverUsed')} +

    + {/* What the key may read. Shown on the row rather than behind a detail + view: the whole reason to narrow a key is to be able to see later + that you did. */} +
    + {scopes ? scopes.map(scope => { + const Icon = SCOPE_ICONS[scope] + return ( + + {Icon && } + {t(`settings.apiScopes.${scope}`)} + + ) + }) : ( + + {t('settings.apiScopes.all')} + + )} +
    +
    + +
  • + ) +} + +/** + * One area the key may read. The whole tile is the control: a bare browser + * checkbox is a small target and ignores the user's accent. + */ +function ScopeOption({ icon: Icon, label, checked, onToggle }: { + icon: LucideIcon + label: string + checked: boolean + onToggle: () => void +}): React.ReactElement { + return ( + + ) +} diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 2e4405b2a..9c011e01e 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -542,6 +542,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'اترك كل شيء مفعّلاً لمفتاح ينبغي أن يرى كل شيء. أي خيار توقفه يُرفض لهذا المفتاح، لا يُحذف من الإجابة فحسب.', 'settings.apiScopes.all': 'كل شيء', + 'settings.apiScopes.noneSelected': 'اختر مجالًا واحدًا على الأقل، وإلا فلن يستطيع المفتاح قراءة أي شيء.', 'settings.apiScopes.limited': '{count} من {total}', 'settings.apiScopes.trips': 'الرحلات', 'settings.apiScopes.days': 'الأيام', @@ -565,6 +566,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'تعذّر إنشاء المفتاح', 'settings.apiKeys.copy': 'نسخ', 'settings.apiKeys.docsHint': 'أرسل المفتاح بصيغة "Authorization: Bearer ..." أو "X-API-Key: ..." إلى /api/v1.', + 'settings.apiKeys.endpoint': 'نقطة النهاية', + 'settings.apiKeys.neverUsed': 'لم يُستخدم قط', + 'settings.apiKeys.loadFailed': 'تعذّر تحميل مفاتيحك. أعد تحميل الصفحة للمحاولة مرة أخرى.', + 'settings.apiKeys.limitReached': 'لديك {max} مفاتيح، وهو الحد الأقصى للحساب الواحد. احذف مفتاحًا لم تعد تستخدمه لإنشاء مفتاح جديد.', + 'settings.apiKeys.copyFailed': 'تعذّر النسخ. حدّد النص وانسخه يدويًا.', 'settings.apiKeys.modal.createTitle': 'إنشاء مفتاح API', 'settings.apiKeys.modal.name': 'الاسم', 'settings.apiKeys.modal.namePlaceholder': 'مثال: Dawarich', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 17001478c..e0fc726d0 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -557,6 +557,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Deixe tudo ligado para uma chave que deve enxergar tudo. O que você desligar é recusado para esta chave, não apenas omitido da resposta.', 'settings.apiScopes.all': 'Tudo', + 'settings.apiScopes.noneSelected': 'Escolha pelo menos uma área, senão a chave não poderia ler nada.', 'settings.apiScopes.limited': '{count} de {total}', 'settings.apiScopes.trips': 'Viagens', 'settings.apiScopes.days': 'Dias', @@ -580,6 +581,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Não foi possível criar a chave', 'settings.apiKeys.copy': 'Copiar', 'settings.apiKeys.docsHint': 'Envie a chave como "Authorization: Bearer ..." ou "X-API-Key: ..." para /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'nunca usada', + 'settings.apiKeys.loadFailed': 'Não foi possível carregar suas chaves. Recarregue a página para tentar de novo.', + 'settings.apiKeys.limitReached': 'Você tem {max} chaves, o máximo por conta. Exclua uma que não usa mais para criar outra.', + 'settings.apiKeys.copyFailed': 'Não foi possível copiar. Selecione o texto e copie manualmente.', 'settings.apiKeys.modal.createTitle': 'Criar chave de API', 'settings.apiKeys.modal.name': 'Nome', 'settings.apiKeys.modal.namePlaceholder': 'ex.: Dawarich', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index d8620f1b0..54fe6dc0b 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -560,6 +560,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Deixa-ho tot activat per a una clau que ho hagi de veure tot. El que desactivis es denega per a aquesta clau, no només es deixa fora de la resposta.', 'settings.apiScopes.all': 'Tot', + 'settings.apiScopes.noneSelected': 'Tria almenys un àmbit; si no, la clau no podria llegir res.', 'settings.apiScopes.limited': '{count} de {total}', 'settings.apiScopes.trips': 'Viatges', 'settings.apiScopes.days': 'Dies', @@ -583,6 +584,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'No s\'ha pogut crear la clau', 'settings.apiKeys.copy': 'Copia', 'settings.apiKeys.docsHint': 'Envia la clau com a "Authorization: Bearer ..." o "X-API-Key: ..." a /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'mai utilitzada', + 'settings.apiKeys.loadFailed': 'No s\'han pogut carregar les teves claus. Torna a carregar la pàgina per tornar-ho a provar.', + 'settings.apiKeys.limitReached': 'Tens {max} claus, el màxim per compte. Esborra\'n una que ja no facis servir per crear-ne una altra.', + 'settings.apiKeys.copyFailed': 'No s\'ha pogut copiar. Selecciona el text i copia\'l a mà.', 'settings.apiKeys.modal.createTitle': 'Crea una clau API', 'settings.apiKeys.modal.name': 'Nom', 'settings.apiKeys.modal.namePlaceholder': 'p. ex. Dawarich', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 12da1ee09..1f415a8b3 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -549,6 +549,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Pro klíč, který má vidět všechno, nechte vše zapnuté. Cokoli vypnete, bude tomuto klíči odepřeno, nejen vynecháno z odpovědi.', 'settings.apiScopes.all': 'Vše', + 'settings.apiScopes.noneSelected': 'Vyber aspoň jednu oblast, jinak by klíč nemohl číst nic.', 'settings.apiScopes.limited': '{count} z {total}', 'settings.apiScopes.trips': 'Cesty', 'settings.apiScopes.days': 'Dny', @@ -572,6 +573,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Klíč se nepodařilo vytvořit', 'settings.apiKeys.copy': 'Kopírovat', 'settings.apiKeys.docsHint': 'Pošli klíč jako "Authorization: Bearer ..." nebo "X-API-Key: ..." na /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'nikdy nepoužit', + 'settings.apiKeys.loadFailed': 'Klíče se nepodařilo načíst. Obnov stránku a zkus to znovu.', + 'settings.apiKeys.limitReached': 'Máš {max} klíčů, víc jeden účet mít nemůže. Smaž klíč, který už nepotřebuješ, a vytvoř nový.', + 'settings.apiKeys.copyFailed': 'Kopírování se nepodařilo. Označ text a zkopíruj ho ručně.', 'settings.apiKeys.modal.createTitle': 'Vytvořit klíč API', 'settings.apiKeys.modal.name': 'Název', 'settings.apiKeys.modal.namePlaceholder': 'např. Dawarich', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 454e407e5..d56ca1829 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -560,6 +560,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Lass alles an, wenn der Schlüssel alles sehen soll. Was du abschaltest, wird für diesen Schlüssel abgelehnt und nicht nur aus der Antwort weggelassen.', 'settings.apiScopes.all': 'Alles', + 'settings.apiScopes.noneSelected': 'Wähle mindestens einen Bereich, sonst dürfte der Schlüssel gar nichts lesen.', 'settings.apiScopes.limited': '{count} von {total}', 'settings.apiScopes.trips': 'Reisen', 'settings.apiScopes.days': 'Tage', @@ -583,6 +584,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Schlüssel konnte nicht erstellt werden', 'settings.apiKeys.copy': 'Kopieren', 'settings.apiKeys.docsHint': 'Schicke den Schlüssel als "Authorization: Bearer ..." oder "X-API-Key: ..." an /api/v1.', + 'settings.apiKeys.endpoint': 'Endpunkt', + 'settings.apiKeys.neverUsed': 'nie genutzt', + 'settings.apiKeys.loadFailed': 'Deine Schlüssel konnten nicht geladen werden. Lade die Seite neu, um es noch einmal zu versuchen.', + 'settings.apiKeys.limitReached': 'Du hast {max} Schlüssel, mehr geht pro Konto nicht. Lösche einen, den du nicht mehr brauchst, um einen neuen zu erstellen.', + 'settings.apiKeys.copyFailed': 'Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand.', 'settings.apiKeys.modal.createTitle': 'API-Schlüssel erstellen', 'settings.apiKeys.modal.name': 'Name', 'settings.apiKeys.modal.namePlaceholder': 'z. B. Dawarich', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index c4182f486..27427f816 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -548,6 +548,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Leave everything on for a key that should see all of it. Anything you switch off is refused for this key, not just left out of the answer.', 'settings.apiScopes.all': 'Everything', + 'settings.apiScopes.noneSelected': 'Pick at least one area, or the key could not read anything.', 'settings.apiScopes.limited': '{count} of {total}', 'settings.apiScopes.trips': 'Trips', 'settings.apiScopes.days': 'Days', @@ -571,6 +572,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Could not create the key', 'settings.apiKeys.copy': 'Copy', 'settings.apiKeys.docsHint': 'Send the key as "Authorization: Bearer ..." or "X-API-Key: ..." to /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'never used', + 'settings.apiKeys.loadFailed': 'Could not load your keys. Reload the page to try again.', + 'settings.apiKeys.limitReached': 'You have {max} keys, the most an account can hold. Delete one you no longer use to create another.', + 'settings.apiKeys.copyFailed': 'Could not copy. Select the text and copy it by hand.', 'settings.apiKeys.modal.createTitle': 'Create API key', 'settings.apiKeys.modal.name': 'Name', 'settings.apiKeys.modal.namePlaceholder': 'e.g. Dawarich', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index ba5d36976..74f0546cd 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -558,6 +558,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Déjalo todo activado para una clave que deba verlo todo. Lo que desactives se le deniega a esta clave, no solo se omite de la respuesta.', 'settings.apiScopes.all': 'Todo', + 'settings.apiScopes.noneSelected': 'Elige al menos un área; si no, la clave no podría leer nada.', 'settings.apiScopes.limited': '{count} de {total}', 'settings.apiScopes.trips': 'Viajes', 'settings.apiScopes.days': 'Días', @@ -581,6 +582,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'No se pudo crear la clave', 'settings.apiKeys.copy': 'Copiar', 'settings.apiKeys.docsHint': 'Envía la clave como "Authorization: Bearer ..." o "X-API-Key: ..." a /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'nunca usada', + 'settings.apiKeys.loadFailed': 'No se pudieron cargar tus claves. Recarga la página para volver a intentarlo.', + 'settings.apiKeys.limitReached': 'Tienes {max} claves, el máximo por cuenta. Elimina una que ya no uses para crear otra.', + 'settings.apiKeys.copyFailed': 'No se pudo copiar. Selecciona el texto y cópialo a mano.', 'settings.apiKeys.modal.createTitle': 'Crear clave API', 'settings.apiKeys.modal.name': 'Nombre', 'settings.apiKeys.modal.namePlaceholder': 'p. ej. Dawarich', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index edb9d9b1d..eb1364481 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -566,6 +566,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Laissez tout activé pour une clé qui doit tout voir. Ce que vous désactivez est refusé à cette clé, et pas seulement omis de la réponse.', 'settings.apiScopes.all': 'Tout', + 'settings.apiScopes.noneSelected': 'Choisissez au moins un domaine, sinon la clé ne pourrait rien lire.', 'settings.apiScopes.limited': '{count} sur {total}', 'settings.apiScopes.trips': 'Voyages', 'settings.apiScopes.days': 'Jours', @@ -589,6 +590,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Impossible de créer la clé', 'settings.apiKeys.copy': 'Copier', 'settings.apiKeys.docsHint': 'Envoyez la clé via "Authorization: Bearer ..." ou "X-API-Key: ..." vers /api/v1.', + 'settings.apiKeys.endpoint': 'Point de terminaison', + 'settings.apiKeys.neverUsed': 'jamais utilisée', + 'settings.apiKeys.loadFailed': 'Impossible de charger vos clés. Rechargez la page pour réessayer.', + 'settings.apiKeys.limitReached': 'Vous avez {max} clés, le maximum pour un compte. Supprimez-en une dont vous n\'avez plus besoin pour en créer une autre.', + 'settings.apiKeys.copyFailed': 'Impossible de copier. Sélectionnez le texte et copiez-le manuellement.', 'settings.apiKeys.modal.createTitle': 'Créer une clé API', 'settings.apiKeys.modal.name': 'Nom', 'settings.apiKeys.modal.namePlaceholder': 'p. ex. Dawarich', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index ef4a4dae9..084492ee7 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -569,6 +569,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Αφήστε τα όλα ενεργά για ένα κλειδί που πρέπει να τα βλέπει όλα. Ό,τι απενεργοποιήσετε απορρίπτεται για αυτό το κλειδί, δεν παραλείπεται απλώς από την απάντηση.', 'settings.apiScopes.all': 'Τα πάντα', + 'settings.apiScopes.noneSelected': 'Διάλεξε τουλάχιστον έναν τομέα, αλλιώς το κλειδί δεν θα μπορούσε να διαβάσει τίποτα.', 'settings.apiScopes.limited': '{count} από {total}', 'settings.apiScopes.trips': 'Ταξίδια', 'settings.apiScopes.days': 'Ημέρες', @@ -592,6 +593,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Δεν ήταν δυνατή η δημιουργία του κλειδιού', 'settings.apiKeys.copy': 'Αντιγραφή', 'settings.apiKeys.docsHint': 'Στείλε το κλειδί ως "Authorization: Bearer ..." ή "X-API-Key: ..." στο /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'δεν χρησιμοποιήθηκε ποτέ', + 'settings.apiKeys.loadFailed': 'Δεν ήταν δυνατή η φόρτωση των κλειδιών σου. Φόρτωσε ξανά τη σελίδα για να δοκιμάσεις πάλι.', + 'settings.apiKeys.limitReached': 'Έχεις {max} κλειδιά, το μέγιστο για έναν λογαριασμό. Διάγραψε ένα που δεν χρησιμοποιείς πια για να δημιουργήσεις νέο.', + 'settings.apiKeys.copyFailed': 'Η αντιγραφή απέτυχε. Επίλεξε το κείμενο και αντίγραψέ το χειροκίνητα.', 'settings.apiKeys.modal.createTitle': 'Δημιουργία κλειδιού API', 'settings.apiKeys.modal.name': 'Όνομα', 'settings.apiKeys.modal.namePlaceholder': 'π.χ. Dawarich', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 75d63aaca..7e0b24cfb 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -559,6 +559,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Hagyj mindent bekapcsolva, ha a kulcs mindent láthat. Amit kikapcsolsz, azt a rendszer megtagadja ettől a kulcstól, nem csak kihagyja a válaszból.', 'settings.apiScopes.all': 'Minden', + 'settings.apiScopes.noneSelected': 'Válassz legalább egy területet, különben a kulcs semmit sem olvashatna.', 'settings.apiScopes.limited': '{count} / {total}', 'settings.apiScopes.trips': 'Utazások', 'settings.apiScopes.days': 'Napok', @@ -582,6 +583,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'A kulcsot nem sikerült létrehozni', 'settings.apiKeys.copy': 'Másolás', 'settings.apiKeys.docsHint': 'Küldd a kulcsot "Authorization: Bearer ..." vagy "X-API-Key: ..." fejlécként a /api/v1 címre.', + 'settings.apiKeys.endpoint': 'Végpont', + 'settings.apiKeys.neverUsed': 'még nem használt', + 'settings.apiKeys.loadFailed': 'Nem sikerült betölteni a kulcsaidat. Töltsd újra az oldalt, és próbáld újra.', + 'settings.apiKeys.limitReached': '{max} kulcsod van, ennél több egy fiókhoz nem tartozhat. Törölj egy már nem használt kulcsot, hogy újat hozhass létre.', + 'settings.apiKeys.copyFailed': 'A másolás nem sikerült. Jelöld ki a szöveget, és másold ki kézzel.', 'settings.apiKeys.modal.createTitle': 'API-kulcs létrehozása', 'settings.apiKeys.modal.name': 'Név', 'settings.apiKeys.modal.namePlaceholder': 'pl. Dawarich', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 489b34f48..22b29cf44 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -555,6 +555,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Biarkan semuanya menyala untuk kunci yang boleh melihat semuanya. Apa pun yang kamu matikan akan ditolak untuk kunci ini, bukan sekadar tidak disertakan dalam jawaban.', 'settings.apiScopes.all': 'Semuanya', + 'settings.apiScopes.noneSelected': 'Pilih setidaknya satu area, kalau tidak kunci ini tidak bisa membaca apa pun.', 'settings.apiScopes.limited': '{count} dari {total}', 'settings.apiScopes.trips': 'Perjalanan', 'settings.apiScopes.days': 'Hari', @@ -578,6 +579,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Kunci tidak dapat dibuat', 'settings.apiKeys.copy': 'Salin', 'settings.apiKeys.docsHint': 'Kirim kunci sebagai "Authorization: Bearer ..." atau "X-API-Key: ..." ke /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'belum pernah dipakai', + 'settings.apiKeys.loadFailed': 'Kunci tidak dapat dimuat. Muat ulang halaman untuk mencoba lagi.', + 'settings.apiKeys.limitReached': 'Kamu punya {max} kunci, batas maksimum per akun. Hapus kunci yang tidak lagi dipakai untuk membuat yang baru.', + 'settings.apiKeys.copyFailed': 'Gagal menyalin. Pilih teksnya dan salin secara manual.', 'settings.apiKeys.modal.createTitle': 'Buat kunci API', 'settings.apiKeys.modal.name': 'Nama', 'settings.apiKeys.modal.namePlaceholder': 'mis. Dawarich', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index cb0263f36..458a60bfd 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -557,6 +557,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Lascia tutto attivo per una chiave che deve vedere ogni cosa. Ciò che disattivi viene rifiutato per questa chiave, non semplicemente omesso dalla risposta.', 'settings.apiScopes.all': 'Tutto', + 'settings.apiScopes.noneSelected': 'Scegli almeno un\'area, altrimenti la chiave non potrebbe leggere nulla.', 'settings.apiScopes.limited': '{count} di {total}', 'settings.apiScopes.trips': 'Viaggi', 'settings.apiScopes.days': 'Giorni', @@ -580,6 +581,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Impossibile creare la chiave', 'settings.apiKeys.copy': 'Copia', 'settings.apiKeys.docsHint': 'Invia la chiave come "Authorization: Bearer ..." oppure "X-API-Key: ..." a /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'mai usata', + 'settings.apiKeys.loadFailed': 'Impossibile caricare le tue chiavi. Ricarica la pagina per riprovare.', + 'settings.apiKeys.limitReached': 'Hai {max} chiavi, il massimo per un account. Eliminane una che non usi più per crearne un\'altra.', + 'settings.apiKeys.copyFailed': 'Impossibile copiare. Seleziona il testo e copialo a mano.', 'settings.apiKeys.modal.createTitle': 'Crea chiave API', 'settings.apiKeys.modal.name': 'Nome', 'settings.apiKeys.modal.namePlaceholder': 'ad es. Dawarich', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 675499ed7..cf3abec2b 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -528,6 +528,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'すべて読み取らせたいキーは、全部オンのままにしてください。オフにした項目は、応答から省かれるのではなく、このキーには拒否されます。', 'settings.apiScopes.all': 'すべて', + 'settings.apiScopes.noneSelected': '少なくとも 1 つの項目を選んでください。選ばないとキーは何も読み取れません。', 'settings.apiScopes.limited': '{total}件中{count}件', 'settings.apiScopes.trips': '旅行', 'settings.apiScopes.days': '日程', @@ -551,6 +552,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'キーを作成できませんでした', 'settings.apiKeys.copy': 'コピー', 'settings.apiKeys.docsHint': 'キーは "Authorization: Bearer ..." または "X-API-Key: ..." として /api/v1 に送信してください。', + 'settings.apiKeys.endpoint': 'エンドポイント', + 'settings.apiKeys.neverUsed': '未使用', + 'settings.apiKeys.loadFailed': 'キーを読み込めませんでした。ページを再読み込みしてもう一度お試しください。', + 'settings.apiKeys.limitReached': 'キーは {max} 個あり、1 アカウントの上限に達しています。新しく作成するには、使わなくなったキーを削除してください。', + 'settings.apiKeys.copyFailed': 'コピーできませんでした。テキストを選択して手動でコピーしてください。', 'settings.apiKeys.modal.createTitle': 'API キーを作成', 'settings.apiKeys.modal.name': '名前', 'settings.apiKeys.modal.namePlaceholder': '例: Dawarich', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index eb09abca2..1357cb049 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -545,6 +545,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': '모든 것을 볼 수 있어야 하는 키라면 전부 켜 두세요. 끈 항목은 이 키에 대해 응답에서 빠지는 정도가 아니라 아예 거부됩니다.', 'settings.apiScopes.all': '전체', + 'settings.apiScopes.noneSelected': '항목을 하나 이상 선택하세요. 그렇지 않으면 키가 아무것도 읽을 수 없습니다.', 'settings.apiScopes.limited': '{total}개 중 {count}개', 'settings.apiScopes.trips': '여행', 'settings.apiScopes.days': '일자', @@ -568,6 +569,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': '키를 만들지 못했습니다', 'settings.apiKeys.copy': '복사', 'settings.apiKeys.docsHint': '키를 "Authorization: Bearer ..." 또는 "X-API-Key: ..." 로 /api/v1 에 보내세요.', + 'settings.apiKeys.endpoint': '엔드포인트', + 'settings.apiKeys.neverUsed': '사용한 적 없음', + 'settings.apiKeys.loadFailed': '키를 불러오지 못했습니다. 페이지를 새로 고친 뒤 다시 시도하세요.', + 'settings.apiKeys.limitReached': '키가 {max}개로 계정당 최대 개수입니다. 새 키를 만들려면 더 이상 쓰지 않는 키를 삭제하세요.', + 'settings.apiKeys.copyFailed': '복사하지 못했습니다. 텍스트를 선택해 직접 복사하세요.', 'settings.apiKeys.modal.createTitle': 'API 키 만들기', 'settings.apiKeys.modal.name': '이름', 'settings.apiKeys.modal.namePlaceholder': '예: Dawarich', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index ab6587085..39b929794 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -558,6 +558,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Laat alles aan staan voor een sleutel die alles mag zien. Alles wat je uitzet, wordt voor deze sleutel geweigerd en niet alleen uit het antwoord weggelaten.', 'settings.apiScopes.all': 'Alles', + 'settings.apiScopes.noneSelected': 'Kies minstens één onderdeel, anders kan de sleutel niets lezen.', 'settings.apiScopes.limited': '{count} van {total}', 'settings.apiScopes.trips': 'Reizen', 'settings.apiScopes.days': 'Dagen', @@ -581,6 +582,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Sleutel kon niet worden aangemaakt', 'settings.apiKeys.copy': 'Kopiëren', 'settings.apiKeys.docsHint': 'Stuur de sleutel als "Authorization: Bearer ..." of "X-API-Key: ..." naar /api/v1.', + 'settings.apiKeys.endpoint': 'Eindpunt', + 'settings.apiKeys.neverUsed': 'nooit gebruikt', + 'settings.apiKeys.loadFailed': 'Je sleutels konden niet worden geladen. Herlaad de pagina om het opnieuw te proberen.', + 'settings.apiKeys.limitReached': 'Je hebt {max} sleutels, het maximum per account. Verwijder er een die je niet meer gebruikt om een nieuwe te maken.', + 'settings.apiKeys.copyFailed': 'Kopiëren is mislukt. Selecteer de tekst en kopieer hem handmatig.', 'settings.apiKeys.modal.createTitle': 'API-sleutel aanmaken', 'settings.apiKeys.modal.name': 'Naam', 'settings.apiKeys.modal.namePlaceholder': 'bijv. Dawarich', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 91bde5c92..a5a95615d 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -557,6 +557,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Zostaw wszystko włączone dla klucza, który ma widzieć całość. Wszystko, co wyłączysz, jest dla tego klucza odrzucane, a nie tylko pomijane w odpowiedzi.', 'settings.apiScopes.all': 'Wszystko', + 'settings.apiScopes.noneSelected': 'Wybierz co najmniej jeden obszar, inaczej klucz nie mógłby niczego odczytać.', 'settings.apiScopes.limited': '{count} z {total}', 'settings.apiScopes.trips': 'Podróże', 'settings.apiScopes.days': 'Dni', @@ -580,6 +581,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Nie udało się utworzyć klucza', 'settings.apiKeys.copy': 'Kopiuj', 'settings.apiKeys.docsHint': 'Wyślij klucz jako "Authorization: Bearer ..." lub "X-API-Key: ..." do /api/v1.', + 'settings.apiKeys.endpoint': 'Endpoint', + 'settings.apiKeys.neverUsed': 'nigdy nieużywany', + 'settings.apiKeys.loadFailed': 'Nie udało się wczytać kluczy. Odśwież stronę, aby spróbować ponownie.', + 'settings.apiKeys.limitReached': 'Masz {max} kluczy, czyli maksimum na konto. Usuń klucz, którego już nie używasz, aby utworzyć nowy.', + 'settings.apiKeys.copyFailed': 'Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie.', 'settings.apiKeys.modal.createTitle': 'Utwórz klucz API', 'settings.apiKeys.modal.name': 'Nazwa', 'settings.apiKeys.modal.namePlaceholder': 'np. Dawarich', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index c1bbc08d0..6af106b62 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -559,6 +559,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Оставьте всё включённым для ключа, который должен видеть всё. Всё, что вы выключите, для этого ключа будет запрещено, а не просто опущено в ответе.', 'settings.apiScopes.all': 'Всё', + 'settings.apiScopes.noneSelected': 'Выберите хотя бы один раздел, иначе ключ не сможет ничего прочитать.', 'settings.apiScopes.limited': '{count} из {total}', 'settings.apiScopes.trips': 'Поездки', 'settings.apiScopes.days': 'Дни', @@ -582,6 +583,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Не удалось создать ключ', 'settings.apiKeys.copy': 'Копировать', 'settings.apiKeys.docsHint': 'Отправляйте ключ как "Authorization: Bearer ..." или "X-API-Key: ..." на /api/v1.', + 'settings.apiKeys.endpoint': 'Эндпоинт', + 'settings.apiKeys.neverUsed': 'не использовался', + 'settings.apiKeys.loadFailed': 'Не удалось загрузить ключи. Обновите страницу, чтобы попробовать снова.', + 'settings.apiKeys.limitReached': 'У вас {max} ключей, это максимум для одного аккаунта. Удалите ненужный ключ, чтобы создать новый.', + 'settings.apiKeys.copyFailed': 'Не удалось скопировать. Выделите текст и скопируйте его вручную.', 'settings.apiKeys.modal.createTitle': 'Создать ключ API', 'settings.apiKeys.modal.name': 'Название', 'settings.apiKeys.modal.namePlaceholder': 'например, Dawarich', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 0fe9c5b1c..0bb6b9e78 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -554,6 +554,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Lämna allt påslaget för en nyckel som ska se allt. Det du stänger av nekas för den här nyckeln, det utelämnas inte bara ur svaret.', 'settings.apiScopes.all': 'Allt', + 'settings.apiScopes.noneSelected': 'Välj minst ett område, annars kan nyckeln inte läsa någonting.', 'settings.apiScopes.limited': '{count} av {total}', 'settings.apiScopes.trips': 'Resor', 'settings.apiScopes.days': 'Dagar', @@ -577,6 +578,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Kunde inte skapa nyckeln', 'settings.apiKeys.copy': 'Kopiera', 'settings.apiKeys.docsHint': 'Skicka nyckeln som "Authorization: Bearer ..." eller "X-API-Key: ..." till /api/v1.', + 'settings.apiKeys.endpoint': 'Ändpunkt', + 'settings.apiKeys.neverUsed': 'aldrig använd', + 'settings.apiKeys.loadFailed': 'Det gick inte att läsa in dina nycklar. Ladda om sidan för att försöka igen.', + 'settings.apiKeys.limitReached': 'Du har {max} nycklar, det högsta antalet per konto. Ta bort en som du inte längre använder för att skapa en ny.', + 'settings.apiKeys.copyFailed': 'Det gick inte att kopiera. Markera texten och kopiera den manuellt.', 'settings.apiKeys.modal.createTitle': 'Skapa API-nyckel', 'settings.apiKeys.modal.name': 'Namn', 'settings.apiKeys.modal.namePlaceholder': 't.ex. Dawarich', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 38d769e3d..5431d6099 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -555,6 +555,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Her şeyi görmesi gereken bir anahtar için hepsini açık bırakın. Kapattığınız her şey bu anahtar için reddedilir, yalnızca yanıttan çıkarılmakla kalmaz.', 'settings.apiScopes.all': 'Her şey', + 'settings.apiScopes.noneSelected': 'En az bir alan seç, yoksa anahtar hiçbir şey okuyamaz.', 'settings.apiScopes.limited': '{total} içinden {count}', 'settings.apiScopes.trips': 'Geziler', 'settings.apiScopes.days': 'Günler', @@ -578,6 +579,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Anahtar oluşturulamadı', 'settings.apiKeys.copy': 'Kopyala', 'settings.apiKeys.docsHint': 'Anahtarı "Authorization: Bearer ..." veya "X-API-Key: ..." olarak /api/v1 adresine gönder.', + 'settings.apiKeys.endpoint': 'Uç nokta', + 'settings.apiKeys.neverUsed': 'hiç kullanılmadı', + 'settings.apiKeys.loadFailed': 'Anahtarların yüklenemedi. Tekrar denemek için sayfayı yenile.', + 'settings.apiKeys.limitReached': '{max} anahtarın var, bir hesap için üst sınır bu. Yeni bir anahtar oluşturmak için artık kullanmadığın birini sil.', + 'settings.apiKeys.copyFailed': 'Kopyalanamadı. Metni seçip elle kopyala.', 'settings.apiKeys.modal.createTitle': 'API anahtarı oluştur', 'settings.apiKeys.modal.name': 'Ad', 'settings.apiKeys.modal.namePlaceholder': 'örn. Dawarich', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 4534bf99e..f7665827d 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -558,6 +558,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Залиште все увімкненим для ключа, який має бачити геть усе. Усе, що ви вимкнете, для цього ключа буде заборонено, а не просто пропущено у відповіді.', 'settings.apiScopes.all': 'Усе', + 'settings.apiScopes.noneSelected': 'Виберіть принаймні один розділ, інакше ключ не зможе нічого прочитати.', 'settings.apiScopes.limited': '{count} з {total}', 'settings.apiScopes.trips': 'Подорожі', 'settings.apiScopes.days': 'Дні', @@ -581,6 +582,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Не вдалося створити ключ', 'settings.apiKeys.copy': 'Копіювати', 'settings.apiKeys.docsHint': 'Надсилайте ключ як "Authorization: Bearer ..." або "X-API-Key: ..." на /api/v1.', + 'settings.apiKeys.endpoint': 'Ендпоінт', + 'settings.apiKeys.neverUsed': 'не використовувався', + 'settings.apiKeys.loadFailed': 'Не вдалося завантажити ключі. Оновіть сторінку, щоб спробувати ще раз.', + 'settings.apiKeys.limitReached': 'У вас {max} ключів, це максимум для одного облікового запису. Видаліть непотрібний ключ, щоб створити новий.', + 'settings.apiKeys.copyFailed': 'Не вдалося скопіювати. Виділіть текст і скопіюйте його вручну.', 'settings.apiKeys.modal.createTitle': 'Створити ключ API', 'settings.apiKeys.modal.name': 'Назва', 'settings.apiKeys.modal.namePlaceholder': 'напр. Dawarich', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 334cd3b31..85234a0f2 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -559,6 +559,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': 'Hãy bật tất cả cho khóa cần xem được mọi thứ. Bất cứ mục nào bạn tắt sẽ bị từ chối với khóa này, chứ không chỉ là bị lược khỏi câu trả lời.', 'settings.apiScopes.all': 'Tất cả', + 'settings.apiScopes.noneSelected': 'Hãy chọn ít nhất một mục, nếu không khóa sẽ không đọc được gì.', 'settings.apiScopes.limited': '{count} trên {total}', 'settings.apiScopes.trips': 'Chuyến đi', 'settings.apiScopes.days': 'Ngày', @@ -582,6 +583,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': 'Không thể tạo khóa', 'settings.apiKeys.copy': 'Sao chép', 'settings.apiKeys.docsHint': 'Gửi khóa dưới dạng "Authorization: Bearer ..." hoặc "X-API-Key: ..." tới /api/v1.', + 'settings.apiKeys.endpoint': 'Điểm cuối', + 'settings.apiKeys.neverUsed': 'chưa từng dùng', + 'settings.apiKeys.loadFailed': 'Không thể tải các khóa của bạn. Hãy tải lại trang để thử lại.', + 'settings.apiKeys.limitReached': 'Bạn đã có {max} khóa, mức tối đa cho một tài khoản. Hãy xóa một khóa không còn dùng để tạo khóa mới.', + 'settings.apiKeys.copyFailed': 'Không thể sao chép. Hãy chọn đoạn văn bản và sao chép thủ công.', 'settings.apiKeys.modal.createTitle': 'Tạo khóa API', 'settings.apiKeys.modal.name': 'Tên', 'settings.apiKeys.modal.namePlaceholder': 'ví dụ Dawarich', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 2fc286d64..5d18ae35c 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -522,6 +522,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': '若希望這組金鑰能看到全部內容,就全部保持開啟。任何你關閉的項目都會對這組金鑰直接拒絕,而不只是從回應中省略。', 'settings.apiScopes.all': '全部', + 'settings.apiScopes.noneSelected': '請至少選擇一項,否則這組金鑰什麼都讀取不了。', 'settings.apiScopes.limited': '{total} 項中的 {count} 項', 'settings.apiScopes.trips': '旅行', 'settings.apiScopes.days': '日期', @@ -545,6 +546,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': '無法建立金鑰', 'settings.apiKeys.copy': '複製', 'settings.apiKeys.docsHint': '將金鑰以 "Authorization: Bearer ..." 或 "X-API-Key: ..." 傳送到 /api/v1。', + 'settings.apiKeys.endpoint': '端點', + 'settings.apiKeys.neverUsed': '從未使用', + 'settings.apiKeys.loadFailed': '無法載入你的金鑰。請重新整理頁面後再試一次。', + 'settings.apiKeys.limitReached': '你已有 {max} 組金鑰,已達每個帳號的上限。請刪除一組不再使用的金鑰後再建立。', + 'settings.apiKeys.copyFailed': '無法複製。請選取文字後手動複製。', 'settings.apiKeys.modal.createTitle': '建立 API 金鑰', 'settings.apiKeys.modal.name': '名稱', 'settings.apiKeys.modal.namePlaceholder': '例如 Dawarich', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 93df25e5c..dcd83b4e0 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -518,6 +518,7 @@ const settings: TranslationStrings = { 'settings.apiScopes.hint': '如果希望这个密钥看到全部内容,就保持全部开启。任何被关闭的内容对该密钥都会被拒绝,而不只是不出现在结果里。', 'settings.apiScopes.all': '全部', + 'settings.apiScopes.noneSelected': '请至少选择一项,否则该密钥什么也读取不了。', 'settings.apiScopes.limited': '{total} 项中的 {count} 项', 'settings.apiScopes.trips': '行程', 'settings.apiScopes.days': '日程', @@ -541,6 +542,11 @@ const settings: TranslationStrings = { 'settings.apiKeys.createFailed': '无法创建密钥', 'settings.apiKeys.copy': '复制', 'settings.apiKeys.docsHint': '把密钥作为 "Authorization: Bearer ..." 或 "X-API-Key: ..." 发送到 /api/v1。', + 'settings.apiKeys.endpoint': '端点', + 'settings.apiKeys.neverUsed': '从未使用', + 'settings.apiKeys.loadFailed': '无法加载你的密钥。请刷新页面后重试。', + 'settings.apiKeys.limitReached': '你已有 {max} 个密钥,已达到每个账户的上限。请删除一个不再使用的密钥后再创建。', + 'settings.apiKeys.copyFailed': '无法复制。请选中文本后手动复制。', 'settings.apiKeys.modal.createTitle': '创建 API 密钥', 'settings.apiKeys.modal.name': '名称', 'settings.apiKeys.modal.namePlaceholder': '例如 Dawarich',