diff --git a/frontend/README.md b/frontend/README.md index df555fd72..840c909b6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -65,7 +65,7 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind - Alerts: searchable, sortable lists with tag filters and 25-row pagination; PromQL and SQL threshold creation/editing, query previews without notifications, hold duration, runtime instances and delivery errors, evaluate/enable/mute/duplicate/delete actions, and Slack/Webhook/Alertmanager targets with masked secrets. PromQL creation requires explicit server capability; existing builder rules are read-only. - Datasets: search, create logs/metrics/traces with an optional custom partition, manage information and ingestion/storage statistics, inspect schema, explore, set/clear retention, configure/disable hot tier when the server enables it, and delete with typed name confirmation. Time partitions cannot be set at creation. Static schema upload, tags/labels, existing partition editing and index management are not supported. - Dashboards: server-backed lists and detail routes, search/tags/favourites/sort/pagination, ownership, full-document saves with conflict resolution, SQL and PromQL tiles, six variable types, URL time ranges and selections, classic import/export, and explicit migration of old browser-local dashboards. Demo dashboards use independent in-memory state per client. -- Team: native users and one-time passwords, role and privilege management, default OIDC role configuration, provider group mappings and role provenance, and API key creation/copy/deletion. Search and 25-row pagination apply to every tab; authorization stays with the backend. +- Team: native users and one-time passwords, role and privilege management, default OIDC role configuration, provider group mappings and role provenance, and API key creation/copy/deletion with configurable expiration from 30 minutes to 90 days or no expiration. New UI-created keys default to 90 days; existing keys keep their original lifetime. Search and 25-row pagination apply to every tab; authorization stays with the backend. - Agents: schema-discovered GenAI telemetry datasets, bounded recent records, time ranges, local search, record inspection and trace handoff. - Cluster: registered node reachability and resource metrics with independent request states; read-only. Errors: paginated OTLP error spans and trace handoff; classic grouped issue analytics remain unavailable. - Workspace setup: `/onboarding` discovers accessible datasets and opens the matching explorer; `/new` explains server scope and provides connection/setup actions. Hosted workspace creation is not exposed by this server. diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index 17a92c17f..dd958739c 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -28,26 +28,32 @@ Logs opt into row/column virtualization for limits above 100 or more than 20 vis Team requests share the cookie session and `credentials: 'include'`. The administrative APIs use `/api/v1`, while API keys use the separate Prism prefix confirmed by `modal/server.rs`. -| Client method | HTTP endpoint | Success response | -| ------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `about(signal?)` | `GET /api/v1/about` | Normalizes `oidcActive`, `capabilities.oidcRoleMapping`, `capabilities.oidcRoleSync`; missing fields become false | -| `listUsers(signal?)` | `GET /api/v1/users` | Users with direct privilege arrays and group-inherited roles; accepts `groupRoles`/`userGroups` and snake_case equivalents; nullable email/picture become absent | -| `createUser(username, roles)` | `POST /api/v1/user/{encoded username}` | Bare JSON role-name array; returns a **text/plain** one-time password | -| `deleteUser(id)` | `DELETE /api/v1/user/{encoded id}` | Checked success; response body is not needed | -| `addUserRoles(id, roles)` | `PATCH /api/v1/user/{encoded id}/role/add` | Bare JSON role-name array; checked success | -| `removeUserRoles(id, roles)` | `PATCH /api/v1/user/{encoded id}/role/remove` | Bare JSON role-name array; only manual OAuth grants are removable | -| `resetPassword(id)` | `POST /api/v1/user/{encoded id}/generate-new-password` | **text/plain** one-time password; native users only | -| `userRoleSources(id, signal?)` | `GET /api/v1/user/{encoded id}/role` | Roles contain `{actions, roleType}`; group roles and optional OAuth `oidc` provenance; issuer and grant lists may be null | -| `listRoles(signal?)` | `GET /api/v1/roles` | Object of privilege arrays; internal roles excluded | -| `putRole(name, privileges)` | `PUT /api/v1/role/{encoded name}` | Bare JSON privilege array replaces the full role; an empty array is valid | -| `deleteRole(name)` | `DELETE /api/v1/role/{encoded name}` | Empty success; default roles and roles in use cannot be deleted | -| `defaultRole(signal?)` | `GET /api/v1/role/default` | JSON string or null | -| `setDefaultRole(name)` | `PUT /api/v1/role/default` | JSON string body; empty success | -| `clearDefaultRole()` | `DELETE /api/v1/role/default` | Empty success | -| `listApiKeys(signal?)` | `GET /api/prism/v1/apikeys` | Array of `{keyId, apiKey, keyName, roles, createdBy, createdAt, modifiedAt}`; `apiKey` is masked as `****` plus the last four characters | -| `createApiKey(name, roles)` | `POST /api/prism/v1/apikeys` | Body `{keyName, roles}`; returns the same shape with the full generated key | -| `getApiKey(id, signal?)` | `GET /api/prism/v1/apikeys/{encoded id}` | Full key, retrieved only on an explicit copy action | -| `deleteApiKey(id)` | `DELETE /api/prism/v1/apikeys/{encoded id}` | Checked success; backend returns `{keyId, keyName, message}` | +| Client method | HTTP endpoint | Success response | +| ---------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `about(signal?)` | `GET /api/v1/about` | Normalizes `oidcActive`, `capabilities.oidcRoleMapping`, `capabilities.oidcRoleSync`; missing fields become false | +| `listUsers(signal?)` | `GET /api/v1/users` | Users with direct privilege arrays and group-inherited roles; accepts `groupRoles`/`userGroups` and snake_case equivalents; nullable email/picture become absent | +| `createUser(username, roles)` | `POST /api/v1/user/{encoded username}` | Bare JSON role-name array; returns a **text/plain** one-time password | +| `deleteUser(id)` | `DELETE /api/v1/user/{encoded id}` | Checked success; response body is not needed | +| `addUserRoles(id, roles)` | `PATCH /api/v1/user/{encoded id}/role/add` | Bare JSON role-name array; checked success | +| `removeUserRoles(id, roles)` | `PATCH /api/v1/user/{encoded id}/role/remove` | Bare JSON role-name array; only manual OAuth grants are removable | +| `resetPassword(id)` | `POST /api/v1/user/{encoded id}/generate-new-password` | **text/plain** one-time password; native users only | +| `userRoleSources(id, signal?)` | `GET /api/v1/user/{encoded id}/role` | Roles contain `{actions, roleType}`; group roles and optional OAuth `oidc` provenance; issuer and grant lists may be null | +| `listRoles(signal?)` | `GET /api/v1/roles` | Object of privilege arrays; internal roles excluded | +| `putRole(name, privileges)` | `PUT /api/v1/role/{encoded name}` | Bare JSON privilege array replaces the full role; an empty array is valid | +| `deleteRole(name)` | `DELETE /api/v1/role/{encoded name}` | Empty success; default roles and roles in use cannot be deleted | +| `defaultRole(signal?)` | `GET /api/v1/role/default` | JSON string or null | +| `setDefaultRole(name)` | `PUT /api/v1/role/default` | JSON string body; empty success | +| `clearDefaultRole()` | `DELETE /api/v1/role/default` | Empty success | +| `listApiKeys(signal?)` | `GET /api/prism/v1/apikeys` | Array of `{keyId, apiKey, keyName, roles, createdBy, createdAt, modifiedAt, expiresAt}`; `apiKey` is masked as `****` plus the last four characters | +| `createApiKey(name, roles, ttlSeconds?)` | `POST /api/prism/v1/apikeys` | Body `{keyName, roles, ttlSeconds?}`; returns the same shape with the full generated key | +| `getApiKey(id, signal?)` | `GET /api/prism/v1/apikeys/{encoded id}` | Full key, retrieved only on an explicit copy action | +| `deleteApiKey(id)` | `DELETE /api/prism/v1/apikeys/{encoded id}` | Checked success; backend returns `{keyId, keyName, message}` | + +API key expiry is optional: `ttlSeconds` must be an integer from 1800 (30 minutes) through 7776000 (90 days), inclusive; invalid values return 400. Omitted or null TTL means no expiration. Responses include `expiresAt`, a UTC timestamp or null; the frontend also accepts an omitted field from older servers for legacy records. Finite lifetimes are elapsed durations from creation, independent of time zones or daylight saving changes. The creation form defaults to 90 days and offers presets, custom minutes/hours/days, and No expiration. TTL cannot be edited after creation. Expired keys remain listed and deletable; their names remain reserved until deletion. + +Both `X-API-KEY` and Bearer authentication return 401 once server time reaches `expiresAt`, and key validation reports false. Temporary API-key sessions, including those created for intra-cluster requests, cannot extend the deadline or refresh as OAuth sessions. Intra-cluster requests on behalf of an expired key return 401. Requests authorized before expiry may finish on the node that authorized them. Expiration changes credential validity, not its assigned roles or an administrator's authority to create other credentials. + +Upgrade **every serving and metadata-writing node before creating finite API keys**. Older nodes ignore expiration and can strip `expiresAt` when rewriting stored user metadata. Rolling back to those versions with finite keys in service is unsafe. Keep node clocks synchronized. All reads validate successful response shapes. Password methods read text, reject empty responses and never JSON-parse successful passwords. Every Team method reports 401 through `onUnauthorized`; 403 remains a visible permission state. Text errors, including root-admin protections, OIDC-managed grants and roles in use, appear in the active dialog. JSON `non_existent_roles` and `roles_not_assigned` errors become readable role lists even when the response advertises text/plain. diff --git a/frontend/docs/validation-review.md b/frontend/docs/validation-review.md index 35176c918..3277fc949 100644 --- a/frontend/docs/validation-review.md +++ b/frontend/docs/validation-review.md @@ -2,6 +2,20 @@ This record distinguishes the current worktree's completed checks from the supplied earlier research. Original Prism source was not recovered; visual comparisons use actual rendered v3.2.4 assets and an independently implemented frontend. No deployed service or the reference checkout was modified. +## API key expiration (2026-10-12) + +API keys support an optional fixed lifetime from 30 minutes through 90 days, or no expiration. The creation form defaults to 90 days and offers presets and custom minutes/hours/days. The server validates integer `ttlSeconds`, persists UTC `expiresAt`, rejects expired keys through both supported headers and the validation endpoint, and prevents temporary sessions from extending the deadline. Existing keys remain nonexpiring. Expired keys stay visible and deletable; the table updates their status without requiring a refresh. + +Five Sol 6.1 workstreams implemented the backend, client/demo contracts, UI, browser regressions and independent Rust/server validation. Three Opus 5.5 rounds covered direction, integrated code quality and final review. The final review approved the changes with no actionable findings. Review fixes add a visible warning if an older server ignores a finite TTL, accept decimal durations such as 1.1 hours without accepting fractional seconds, preserve Basic-auth precedence for mixed headers, and avoid a duplicate user scan during API-key authentication. + +- TypeScript and the production frontend build pass; **2,200 unit tests pass across 89 files**, with one existing skipped test. +- **50 browser tests pass**: 11 API-key expiration regressions and 39 existing Team cases. They cover exact POST durations, boundaries, no expiration, missing/null legacy responses, automatic expiry, deletion, native-user controls and mobile accessibility. Chromium runs used the shared Playwright lock and an isolated port so another checkout's Vite server could not answer them. +- Formatting and diff checks pass. The custom-duration form was also inspected in the collaborative browser. +- **20 unique focused Rust tests pass**, including malformed persisted expiry, inclusive TTL bounds, exact expiry instants, legacy deserialization and response masking. The final server build passes. +- **49 live HTTP/persistence assertions pass** against the final build on a disposable single-node server: 30-minute/90-day/null/omitted creation, invalid TTL rejection, both authentication headers, key validation, expiration after restart and retained list/detail records. Expiry was simulated by changing only temporary persisted metadata while the server was stopped; legacy missing-expiry records stayed active. The server was stopped after verification. This did not exercise a real 30-minute wait, multiple tenants, or a cluster upgrade. + +The [Mayfly implementation and validation record](https://mayfly-celld.tail835e0d.ts.net/w/46ncdGypknzAj7dnRkJ8bQ?page=4152b588-95ed-4b57-a675-0f7c9d24a609#J63UryiLZKvb1G5vnY7RVQhXPDhXy-GpIhjXiCB_TzM) sits under the existing Team page. No production deployment was performed. All serving and metadata-writing nodes must be upgraded before creating finite keys: old nodes ignore expiration and may remove the field when rewriting metadata. Expiration does not interrupt requests already authorized or reduce an administrator's existing authority to create other credentials. + ## Logs explorer quality follow-up (2026-10-12) Opus 5.5's quality review identified false empty results when queries were blocked and stale filter notices. Both are fixed. The follow-up centralizes persisted explorer state in a reducer and shares compiled criteria between row and histogram queries while preserving captured bounds. Shared filter helpers keep group additions distinct from whole-expression narrowing. Both table modes now share sorting, labels, summary formatting and severity badges; histogram rendering consumes the aggregate's exact geometry. Demo implementations are separated from transport guards, saved-view metadata is typed and decoded once per response, and context-field selection is consistent. Ordinary demo SQL retains its 100-row default; aggregate inputs remain unlimited. diff --git a/frontend/e2e/api-key-expiration.spec.ts b/frontend/e2e/api-key-expiration.spec.ts new file mode 100644 index 000000000..5bc8c7673 --- /dev/null +++ b/frontend/e2e/api-key-expiration.spec.ts @@ -0,0 +1,235 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +const keysPath = '/api/prism/v1/apikeys'; +const now = '2026-10-12T12:00:00.000Z'; +function key(name: string, expiresAt?: string | null) { + return { + keyId: name, + keyName: name, + apiKey: '****1234', + roles: ['readers'], + createdBy: 'admin', + createdAt: now, + modifiedAt: now, + ...(expiresAt === undefined ? {} : { expiresAt }), + }; +} +const row = (page: Page, name: string) => + page.getByRole('row').filter({ has: page.getByText(name, { exact: true }) }); +async function live( + page: Page, + initial: ReturnType[] = [], + legacy: false | 'omit' | 'null' = false, +) { + let keys = [...initial]; + const posts: unknown[] = []; + const deletes: string[] = []; + await page.route('**/api/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === keysPath) { + if (request.method() === 'POST') { + const body = request.postDataJSON(); + posts.push(body); + const expiresAt = + body.ttlSeconds === null + ? null + : new Date(Date.parse(now) + body.ttlSeconds * 1000).toISOString(); + const result = key( + body.keyName, + legacy === 'omit' ? undefined : legacy === 'null' ? null : expiresAt, + ); + keys.push(result); + return route.fulfill({ json: { ...result, apiKey: 'secret-key-1234' } }); + } + return route.fulfill({ json: keys }); + } + if (path.startsWith(`${keysPath}/`) && request.method() === 'DELETE') { + const id = decodeURIComponent(path.slice(keysPath.length + 1)); + deletes.push(id); + keys = keys.filter((entry) => entry.keyId !== id); + return route.fulfill({ status: 200, body: '' }); + } + if (path === '/api/v1/about') return route.fulfill({ json: {} }); + if (path === '/api/v1/logstream') return route.fulfill({ json: [] }); + if (path === '/api/v1/role/default') return route.fulfill({ json: null }); + if (path === '/api/v1/roles') return route.fulfill({ json: { readers: [] } }); + if (path === '/api/v1/users') return route.fulfill({ json: [] }); + return route.fulfill({ status: 404, body: 'Not found' }); + }); + await page.goto('/team?tab=apikeys'); + await expect(page.getByRole('table')).toBeVisible(); + return { posts, deletes }; +} +async function create(page: Page, name: string) { + await page.getByRole('button', { name: 'Add API key', exact: true }).click(); + const sheet = page.getByRole('dialog', { name: 'Add API key', exact: true }); + await sheet.getByLabel('API key name', { exact: true }).fill(name); + await sheet.getByRole('checkbox', { name: 'readers', exact: true }).check(); + return sheet; +} + +for (const selection of [ + { name: 'default 90 days', seconds: 7776000 }, + { name: 'minimum 30 minutes', preset: '1800', seconds: 1800 }, + { name: 'custom 45 minutes', duration: '45', unit: 'minutes', seconds: 2700 }, + { name: 'custom 2 hours', duration: '2', unit: 'hours', seconds: 7200 }, + { name: 'custom 1.1 hours', duration: '1.1', unit: 'hours', seconds: 3960 }, + { name: 'no expiration', preset: 'none', seconds: null }, +]) { + test(`API key creation sends ${selection.name} and displays server expiration`, async ({ + page, + }) => { + const { posts } = await live(page); + const sheet = await create(page, 'automation'); + await expect(sheet.getByLabel('Expiration', { exact: true })).toHaveValue('7776000'); + if (selection.preset) + await sheet.getByLabel('Expiration', { exact: true }).selectOption(selection.preset); + if (selection.duration) { + await sheet.getByLabel('Expiration', { exact: true }).selectOption('custom'); + await sheet.getByLabel('Duration unit', { exact: true }).selectOption(selection.unit!); + await sheet.getByLabel('Duration', { exact: true }).fill(selection.duration); + } + await sheet.getByRole('button', { name: 'Create API key', exact: true }).click(); + await expect(sheet.getByLabel('API key', { exact: true })).toHaveValue('secret-key-1234'); + expect(posts).toEqual([ + { keyName: 'automation', roles: ['readers'], ttlSeconds: selection.seconds }, + ]); + const expiresAt = + selection.seconds === null + ? null + : new Date(Date.parse(now) + selection.seconds * 1000).toISOString(); + if (expiresAt) await expect(sheet.locator('time')).toHaveAttribute('datetime', expiresAt); + else await expect(sheet.getByText('No expiration.', { exact: true })).toBeVisible(); + await sheet.getByRole('button', { name: 'Done', exact: true }).click(); + if (expiresAt) + await expect(row(page, 'automation').locator(`time[datetime="${expiresAt}"]`)).toBeVisible(); + else await expect(row(page, 'automation')).toContainText('No expiration'); + }); +} + +test('custom duration rejects out-of-range and fractional-second values without POSTing', async ({ + page, +}) => { + const { posts } = await live(page); + const sheet = await create(page, 'invalid-duration'); + await sheet.getByLabel('Expiration', { exact: true }).selectOption('custom'); + for (const [value, unit] of [ + ['29', 'minutes'], + ['91', 'days'], + ['30.001', 'minutes'], + ['', 'hours'], + ]) { + await sheet.getByLabel('Duration unit', { exact: true }).selectOption(unit); + await sheet.getByLabel('Duration', { exact: true }).fill(value); + await expect(sheet.getByRole('alert')).toHaveText( + 'Enter a duration from 30 minutes to 90 days, in whole seconds.', + ); + await expect(sheet.getByRole('button', { name: 'Create API key', exact: true })).toBeDisabled(); + } + expect(posts).toEqual([]); + await sheet.getByLabel('Duration', { exact: true }).fill('0.5'); + await expect(sheet.getByRole('button', { name: 'Create API key', exact: true })).toBeEnabled(); +}); + +test('custom duration input range follows the selected unit', async ({ page }) => { + await live(page); + const sheet = await create(page, 'bounded-duration'); + await sheet.getByLabel('Expiration', { exact: true }).selectOption('custom'); + const duration = sheet.getByLabel('Duration', { exact: true }); + for (const [unit, min, max] of [ + ['minutes', '30', '129600'], + ['hours', '0.5', '2160'], + ['days', String(1 / 48), '90'], + ]) { + await sheet.getByLabel('Duration unit', { exact: true }).selectOption(unit); + await expect(duration).toHaveAttribute('min', min); + await expect(duration).toHaveAttribute('max', max); + await expect(duration).toHaveAttribute('step', 'any'); + await duration.fill(min); + expect(await duration.evaluate((input: HTMLInputElement) => input.validity.valid)).toBe(true); + await expect(sheet.getByRole('button', { name: 'Create API key', exact: true })).toBeEnabled(); + } + await sheet.getByLabel('Duration unit', { exact: true }).selectOption('minutes'); + await duration.fill('29'); + expect(await duration.evaluate((input: HTMLInputElement) => input.validity.rangeUnderflow)).toBe( + true, + ); + await duration.fill('129601'); + expect(await duration.evaluate((input: HTMLInputElement) => input.validity.rangeOverflow)).toBe( + true, + ); +}); + +test('expiration updates at the deadline, disables copying and preserves deletion and legacy keys', async ({ + page, +}) => { + await page.clock.install({ time: new Date(now) }); + const expiration = '2026-10-12T12:00:30.000Z'; + const { deletes } = await live(page, [ + key('soon', expiration), + key('legacy'), + key('forever', null), + ]); + const soon = row(page, 'soon'); + await expect(soon.getByText('Expired', { exact: true })).toHaveCount(0); + await expect(soon.getByRole('button', { name: 'Copy API key for soon' })).toBeEnabled(); + await page.clock.fastForward(30_000); + await expect(soon.getByText('Expired', { exact: true })).toBeVisible(); + await expect(soon.getByRole('button', { name: 'Copy API key for soon' })).toBeDisabled(); + for (const name of ['legacy', 'forever']) { + await expect(row(page, name)).toContainText('No expiration'); + await expect( + row(page, name).getByRole('button', { name: `Copy API key for ${name}` }), + ).toBeEnabled(); + } + await soon.getByRole('button', { name: 'Delete API key soon' }).click(); + const dialog = page.getByRole('dialog', { name: 'Delete API key', exact: true }); + await dialog.getByLabel('Confirmation name').fill('soon'); + await dialog.locator('[data-dialog-confirm]').click(); + await expect(soon).toHaveCount(0); + expect(deletes).toEqual(['soon']); +}); + +for (const legacy of ['omit', 'null'] as const) { + test(`finite key creation warns when server expiration is ${legacy}`, async ({ page }) => { + const { posts } = await live(page, [], legacy); + const sheet = await create(page, 'legacy-create'); + await sheet.getByRole('button', { name: 'Create API key', exact: true }).click(); + await expect(sheet.getByLabel('API key', { exact: true })).toHaveValue('secret-key-1234'); + expect(posts).toEqual([{ keyName: 'legacy-create', roles: ['readers'], ttlSeconds: 7776000 }]); + await expect( + sheet.getByText( + 'The server did not apply expiration. This key does not expire; delete it if that is not intended.', + { exact: true }, + ), + ).toBeVisible(); + await expect(sheet.locator('time')).toHaveCount(0); + await sheet.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(row(page, 'legacy-create')).toContainText('No expiration'); + }); +} + +test('expiration controls are accessible on mobile and absent for native users', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await live(page); + const sheet = await create(page, 'mobile-key'); + await sheet.getByLabel('Expiration', { exact: true }).selectOption('custom'); + await sheet.getByLabel('Duration unit', { exact: true }).selectOption('minutes'); + await sheet.getByLabel('Duration', { exact: true }).fill('45'); + await page.emulateMedia({ reducedMotion: 'reduce' }); + expect( + (await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()) + .violations, + ).toEqual([]); + await page.keyboard.press('Escape'); + await page.getByRole('tab', { name: 'Users', exact: true }).click(); + await page.getByRole('button', { name: 'Add user', exact: true }).click(); + const user = page.getByRole('dialog', { name: 'Create new user', exact: true }); + await expect(user.getByLabel('Username', { exact: true })).toBeVisible(); + await expect(user.getByLabel('Expiration', { exact: true })).toHaveCount(0); + await expect(user.getByLabel('Duration', { exact: true })).toHaveCount(0); +}); diff --git a/frontend/src/features/team/ApiKeysTab.tsx b/frontend/src/features/team/ApiKeysTab.tsx index 6c8ba8f4e..4476ddaf0 100644 --- a/frontend/src/features/team/ApiKeysTab.tsx +++ b/frontend/src/features/team/ApiKeysTab.tsx @@ -16,6 +16,7 @@ import { CredentialSheet } from './CredentialSheet'; import { TeamSearch, useTeamSearch } from './shared'; import { useCollection } from '../../hooks/useCollection'; import { useMutation } from '../../hooks/useMutation'; +import { isApiKeyExpired } from './apiKeyExpiration'; export async function copyApiKey(key: Promise, active: { current: boolean }) { try { @@ -39,7 +40,7 @@ export async function copyApiKey(key: Promise, active: { current: boolea } } -function CopyKey({ apiKey }: { apiKey: ApiKey }) { +function CopyKey({ apiKey, expired }: { apiKey: ApiKey; expired: boolean }) { const { client } = useApp(); const [status, setStatus] = useState(''); const mutation = useMutation(); @@ -64,7 +65,7 @@ function CopyKey({ apiKey }: { apiKey: ApiKey }) { size="sm" aria-label={`Copy API key for ${apiKey.keyName}`} onClick={copy} - disabled={mutation.pending} + disabled={mutation.pending || expired} >