diff --git a/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.module.css b/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.module.css new file mode 100644 index 00000000..d9598037 --- /dev/null +++ b/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.module.css @@ -0,0 +1,21 @@ +.groupedCards { + & > * { + border-radius: 0; + + &:first-child { + border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0; + } + + &:last-child { + border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg); + } + + &:only-child { + border-radius: var(--mantine-radius-lg); + } + + & + * { + border-top: 1px solid var(--mantine-color-default-border); + } + } +} diff --git a/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.stories.tsx b/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.stories.tsx new file mode 100644 index 00000000..0cea4c66 --- /dev/null +++ b/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import ApiKeysContainer from './ApiKeysContainer'; +import ApiKeysContainerSkeleton from './Skeleton.ApiKeysContainer'; + +const meta: Meta = { + title: 'Features/Settings/ApiKeysContainer', + component: ApiKeysContainer, +}; + +export default meta; + +type Story = StoryObj; + +/** Three mock API keys — the default state. */ +export const Default: Story = {}; + +/** No keys yet — shows the empty state UI. */ +export const Empty: Story = { + args: { + initialKeys: [], + }, +}; + +export const Skeleton: Story = { + render: () => , +}; diff --git a/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.tsx b/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.tsx new file mode 100644 index 00000000..9234443a --- /dev/null +++ b/src/webapp/features/settings/containers/apiKeysContainer/ApiKeysContainer.tsx @@ -0,0 +1,285 @@ +'use client'; + +import classes from './ApiKeysContainer.module.css'; +import { + ActionIcon, + Box, + Button, + Card, + Code, + Container, + CopyButton, + Group, + Modal, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { useState } from 'react'; +import { MdDelete, MdKey } from 'react-icons/md'; +import { DANGER_OVERLAY_PROPS } from '@/styles/overlays'; +import { useApiKeys } from '../../lib/apiKeys/useApiKeys'; +import type { ApiKey, NewApiKey } from '../../lib/apiKeys/types'; + +function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +interface ApiKeysContainerProps { + /** + * Seed the key list. Defaults to MOCK_API_KEYS. + * Remove this prop when wiring up real data via the hook. + */ + initialKeys?: ApiKey[]; +} + +export default function ApiKeysContainer({ + initialKeys, +}: ApiKeysContainerProps) { + const { keys, createKey, revokeKey } = useApiKeys(initialKeys); + + // Create modal + const [createOpened, { open: openCreate, close: closeCreate }] = + useDisclosure(false); + const [keyName, setKeyName] = useState(''); + + // Token reveal modal (shown once after creation) + const [newKey, setNewKey] = useState(null); + const [tokenOpened, { open: openToken, close: closeToken }] = + useDisclosure(false); + + // Revoke confirm modal + const [revokeTarget, setRevokeTarget] = useState(null); + const [revokeOpened, { open: openRevoke, close: closeRevoke }] = + useDisclosure(false); + + function handleCreate() { + if (!keyName.trim()) return; + const created = createKey(keyName.trim()); + setKeyName(''); + closeCreate(); + setNewKey(created); + openToken(); + } + + function handleRevokeClick(key: ApiKey) { + setRevokeTarget(key); + openRevoke(); + } + + function handleRevokeConfirm() { + if (!revokeTarget) return; + revokeKey(revokeTarget.id); + setRevokeTarget(null); + closeRevoke(); + } + + return ( + + + {/* Header */} + + + API Keys + + Authenticate requests to the Semble API + + + + + + {/* Key list */} + + {keys.length === 0 ? ( + + + + + + + No keys created + + + Create a key to get started + + + + + + + ) : ( + keys.map((key) => ( + + + + + {key.name} + + + {key.prefix} + {'••••••••••••••••••••••'} + + + + Created {formatDate(key.createdAt)} + + + · + + + {key.lastUsedAt + ? `Last used ${formatDate(key.lastUsedAt)}` + : 'Never used'} + + + + handleRevokeClick(key)} + aria-label={`Revoke ${key.name}`} + > + + + + + )) + )} + + + + {/* Create key modal */} + + + setKeyName(e.currentTarget.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + data-autofocus + /> + + + + + + + + {/* Token reveal modal — shown once immediately after creation */} + + + + Copy this key now — it will not be shown again. + + + {newKey?.name} + + {({ copied, copy }) => ( + + + {newKey?.token} + + + + )} + + + + + + + + {/* Revoke confirm modal */} + + + + This key will stop working immediately and cannot be recovered. + + + + + + + ); +} diff --git a/src/webapp/features/settings/containers/apiKeysContainer/Skeleton.ApiKeysContainer.tsx b/src/webapp/features/settings/containers/apiKeysContainer/Skeleton.ApiKeysContainer.tsx new file mode 100644 index 00000000..32ddecd7 --- /dev/null +++ b/src/webapp/features/settings/containers/apiKeysContainer/Skeleton.ApiKeysContainer.tsx @@ -0,0 +1,32 @@ +import { Card, Container, Group, Skeleton, Stack } from '@mantine/core'; +import classes from './ApiKeysContainer.module.css'; + +export default function ApiKeysContainerSkeleton() { + return ( + + + + + + + + + + + {[1, 2, 3].map((i) => ( + + + + + + + + + + + ))} + + + + ); +} diff --git a/src/webapp/features/settings/lib/apiKeys/mockData.ts b/src/webapp/features/settings/lib/apiKeys/mockData.ts new file mode 100644 index 00000000..1ee99182 --- /dev/null +++ b/src/webapp/features/settings/lib/apiKeys/mockData.ts @@ -0,0 +1,28 @@ +import type { ApiKey } from './types'; + +export const MOCK_API_KEYS: ApiKey[] = [ + { + id: 'mock-1', + name: 'Production server', + prefix: 'sk_ab12cd34', + createdAt: new Date('2026-01-15'), + lastUsedAt: new Date('2026-04-28'), + expiresAt: null, + }, + { + id: 'mock-2', + name: 'CI/CD pipeline', + prefix: 'sk_ef56gh78', + createdAt: new Date('2026-02-03'), + lastUsedAt: new Date('2026-04-20'), + expiresAt: null, + }, + { + id: 'mock-3', + name: 'Local development', + prefix: 'sk_ij90kl12', + createdAt: new Date('2026-04-01'), + lastUsedAt: null, + expiresAt: null, + }, +]; diff --git a/src/webapp/features/settings/lib/apiKeys/types.ts b/src/webapp/features/settings/lib/apiKeys/types.ts new file mode 100644 index 00000000..452f4010 --- /dev/null +++ b/src/webapp/features/settings/lib/apiKeys/types.ts @@ -0,0 +1,15 @@ +export interface ApiKey { + id: string; + /** Human-readable label given by the user */ + name: string; + /** First ~12 chars of the token (e.g. "sk_ab12cd34"). Full token is never stored after creation. */ + prefix: string; + createdAt: Date; + lastUsedAt: Date | null; + expiresAt: Date | null; +} + +/** Returned only at creation time. The full token is never retrievable again. */ +export interface NewApiKey extends ApiKey { + token: string; +} diff --git a/src/webapp/features/settings/lib/apiKeys/useApiKeys.ts b/src/webapp/features/settings/lib/apiKeys/useApiKeys.ts new file mode 100644 index 00000000..81910db3 --- /dev/null +++ b/src/webapp/features/settings/lib/apiKeys/useApiKeys.ts @@ -0,0 +1,47 @@ +'use client'; + +// Mock implementation backed by useState. +// +// To connect real data, replace this hook with TanStack Query: +// - List: useSuspenseQuery({ queryKey: apiKeyKeys.list(), queryFn: () => dal.listApiKeys() }) +// - Create: useMutation({ mutationFn: (name) => dal.createApiKey(name), onSuccess: () => queryClient.invalidateQueries(...) }) +// - Revoke: useMutation({ mutationFn: (id) => dal.revokeApiKey(id), onSuccess: () => queryClient.invalidateQueries(...) }) +// +// The container's `initialKeys` prop can be dropped once real data is in place. + +import { useCallback, useState } from 'react'; +import type { ApiKey, NewApiKey } from './types'; +import { MOCK_API_KEYS } from './mockData'; + +function generateToken(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const random = Array.from({ length: 32 }, () => + chars[Math.floor(Math.random() * chars.length)], + ).join(''); + return `sk_${random}`; +} + +export function useApiKeys(initialKeys: ApiKey[] = MOCK_API_KEYS) { + const [keys, setKeys] = useState(initialKeys); + + const createKey = useCallback((name: string): NewApiKey => { + const token = generateToken(); + const newKey: NewApiKey = { + id: crypto.randomUUID(), + name, + prefix: token.slice(0, 10), + createdAt: new Date(), + lastUsedAt: null, + expiresAt: null, + token, + }; + setKeys((prev) => [newKey, ...prev]); + return newKey; + }, []); + + const revokeKey = useCallback((id: string): void => { + setKeys((prev) => prev.filter((k) => k.id !== id)); + }, []); + + return { keys, createKey, revokeKey }; +}