From 28b9112781b2b5faa34de2c7e54fced400dd3137 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 6 Nov 2025 11:41:28 +0100 Subject: [PATCH] settings --- frontend/src/settings/SettingsModal.jsx | 1339 +---------------- .../components/CapabilityDropdown.jsx | 147 ++ .../settings/sections/ApiTokensSection.jsx | 407 +++++ .../sections/CapabilitySetsSection.jsx | 582 +++++++ .../src/settings/sections/PasskeysSection.jsx | 172 +++ frontend/src/settings/sections/index.js | 15 + frontend/src/styles.css | 19 +- 7 files changed, 1388 insertions(+), 1293 deletions(-) create mode 100644 frontend/src/settings/components/CapabilityDropdown.jsx create mode 100644 frontend/src/settings/sections/ApiTokensSection.jsx create mode 100644 frontend/src/settings/sections/CapabilitySetsSection.jsx create mode 100644 frontend/src/settings/sections/PasskeysSection.jsx create mode 100644 frontend/src/settings/sections/index.js diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.jsx index a62a872..589c13a 100644 --- a/frontend/src/settings/SettingsModal.jsx +++ b/frontend/src/settings/SettingsModal.jsx @@ -1,210 +1,41 @@ -import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import PanelHeader from '../ui/PanelHeader'; -import { CheckIcon, ChevronDownIcon, IconX } from '../ui/icons'; - -const SECTIONS = [ - { - id: 'passkeys', - label: 'Passkeys', - }, - { - id: 'capabilitySets', - label: 'Capability sets', - }, - { - id: 'apiTokens', - label: 'API tokens', - }, -]; - -const CapabilityDropdown = ({ - id, - options = [], - selectedValues = [], - onSelect, - onDeselect, - formatLabel, - disabled = false, - loading = false, - summaryLabel = 'capabilities', -}) => { - const anchorRef = useRef(null); - const menuRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - - const close = useCallback(() => { - setIsOpen(false); - }, []); - - const toggle = useCallback(() => { - if (disabled) { - return; - } - setIsOpen((previous) => !previous); - }, [disabled]); - - useEffect(() => { - if (!isOpen) { - return undefined; - } - const handlePointer = (event) => { - if (anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) { - return; - } - close(); - }; - - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - close(); - } - }; - - document.addEventListener('mousedown', handlePointer); - document.addEventListener('touchstart', handlePointer, { passive: true }); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handlePointer); - document.removeEventListener('touchstart', handlePointer); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [close, isOpen]); - - const handleOptionClick = useCallback((value) => { - if (selectedValues.includes(value)) { - onDeselect?.(value); - } else { - onSelect?.(value); - } - }, [onDeselect, onSelect, selectedValues]); - - const total = options.length; - const selectedCount = selectedValues.length; - - const summaryText = total - ? `${selectedCount}/${total} ${summaryLabel} enabled` - : selectedCount - ? `${selectedCount} ${summaryLabel} selected` - : loading - ? `Loading ${summaryLabel}…` - : `No ${summaryLabel}`; - - const buttonText = selectedCount || loading || total - ? summaryText - : `Select ${summaryLabel}`; - - const emptyMessage = loading - ? `Loading ${summaryLabel}…` - : `No ${summaryLabel} available.`; - - const isDisabled = disabled || (total === 0 && !selectedCount) || loading; - - return ( -
- - {isOpen ? ( - - ) : null} -
- ); -}; +import { DEFAULT_SETTINGS_SECTIONS } from './sections'; const SettingsModal = ({ - open, + open = false, onClose, - tokens = [], - loading = false, - creating = false, - deletingId = null, - regeneratingId = null, - capabilitySets = [], - capabilitySetsLoading = false, - creatingCapabilitySet = false, - savingCapabilitySetId = null, - deletingCapabilitySetId = null, - supportsCapabilitySetLabels = false, - capabilities = [], - capabilitiesLoading = false, - onRefresh, - onRefreshCapabilitySets, - onRefreshCapabilities, - onCreate: onCreateToken, - onDelete: onDeleteToken, - onRegenerate: onRegenerateToken, - onCreateCapabilitySet, - onUpdateCapabilitySet, - onDeleteCapabilitySet, - createdToken = null, - onDismissCreatedToken, - passkeys = [], - passkeysSupported = null, - passkeysLoading = false, - registeringPasskey = false, - revokingPasskeyId = null, - onRefreshPasskeys, - onRegisterPasskey, - onRevokePasskey, + sections, + defaultSectionId, + ...sectionProps }) => { - const defaultSection = SECTIONS[0]?.id || 'passkeys'; - const [activeSection, setActiveSection] = useState(defaultSection); - const [newTokenLabel, setNewTokenLabel] = useState(''); - const [newTokenExpires, setNewTokenExpires] = useState(''); - const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState(''); - const [formError, setFormError] = useState(null); - const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); - const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState(''); - const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState(''); - const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]); - const [capabilitySetFormError, setCapabilitySetFormError] = useState(null); - const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null); - const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState(''); - const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState(''); - const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]); - const [capabilitySetEditError, setCapabilitySetEditError] = useState(null); + const sectionList = useMemo(() => { + if (Array.isArray(sections) && sections.length) { + return sections; + } + return DEFAULT_SETTINGS_SECTIONS; + }, [sections]); + + const firstSectionId = sectionList[0]?.id ?? null; + const resolvedDefaultSection = defaultSectionId || firstSectionId; + + const [activeSection, setActiveSection] = useState(resolvedDefaultSection); + + useEffect(() => { + if (!open) { + setActiveSection(resolvedDefaultSection); + return; + } + const hasActiveSection = sectionList.some((section) => section.id === activeSection); + if (!hasActiveSection) { + setActiveSection(resolvedDefaultSection); + } + }, [open, sectionList, resolvedDefaultSection, activeSection]); const handleBackdropClick = useCallback(() => { onClose?.(); @@ -214,1092 +45,21 @@ const SettingsModal = ({ event.stopPropagation(); }, []); - useEffect(() => { - if (!open) { - setActiveSection(defaultSection); - setNewTokenLabel(''); - setNewTokenExpires(''); - setNewTokenCapabilitySetId(''); - setFormError(null); - setNewPasskeyNickname(''); - setNewCapabilitySetSlug(''); - setNewCapabilitySetLabel(''); - setNewCapabilitySetCapabilities([]); - setCapabilitySetFormError(null); - setEditingCapabilitySetId(null); - setEditCapabilitySetSlug(''); - setEditCapabilitySetLabel(''); - setEditCapabilitySetCapabilities([]); - setCapabilitySetEditError(null); - } - }, [open, defaultSection]); - - const formatDateTime = useCallback((value) => { - if (!value) { - return '—'; - } - const timestamp = new Date(value); - if (Number.isNaN(timestamp.getTime())) { - return value; - } - return timestamp.toLocaleString(); - }, []); - - const handleRefresh = useCallback(() => { - onRefresh?.(); - }, [onRefresh]); - - const handleCopyToken = useCallback(() => { - if (!createdToken) { - return; - } - if (navigator?.clipboard?.writeText) { - navigator.clipboard.writeText(createdToken).catch(() => {}); - } - }, [createdToken]); - - const handleDismissSecret = useCallback(() => { - onDismissCreatedToken?.(); - }, [onDismissCreatedToken]); - - const handlePasskeyRefresh = useCallback(() => { - onRefreshPasskeys?.(); - }, [onRefreshPasskeys]); - - const handlePasskeyRegister = useCallback( - async (event) => { - event.preventDefault(); - const nickname = newPasskeyNickname.trim(); - const result = await onRegisterPasskey?.({ nickname }); - if (result?.ok) { - setNewPasskeyNickname(''); - } - }, - [newPasskeyNickname, onRegisterPasskey], - ); - - const handlePasskeyRevoke = useCallback( - async (passkey) => { - if (!passkey?.id) { - return; - } - const reasonInput = window.prompt('Optional reason for revoking this passkey:', ''); - const reason = reasonInput ? reasonInput.trim() : undefined; - await onRevokePasskey?.(passkey.id, reason); - }, - [onRevokePasskey], - ); - - const capabilitySetOptions = useMemo( - () => capabilitySets.map((set) => ({ - value: set.id, - label: set.label || set.slug || set.id, - capabilities: Array.isArray(set.capabilities) ? set.capabilities : [], - })), - [capabilitySets], - ); - - const capabilitySetMap = useMemo( - () => Object.fromEntries(capabilitySetOptions.map((option) => [option.value, option])), - [capabilitySetOptions], - ); - - const capabilitySelectionOptions = useMemo(() => ( - Array.isArray(capabilities) - ? capabilities.map((capability) => { - if (typeof capability !== 'string') { - return { value: capability, label: String(capability) }; - } - const [namespace, action] = capability.split(':'); - if (!namespace || !action) { - return { value: capability, label: capability }; - } - const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; - const formattedAction = action.replace(/_/g, ' '); - return { - value: capability, - label: `${formattedNamespace}: ${formattedAction}`, - }; - }) - : [] - ), [capabilities]); - - const capabilityLabelMap = useMemo(() => { - const map = new Map(); - capabilitySelectionOptions.forEach(({ value, label }) => { - map.set(value, label || String(value)); - }); - return map; - }, [capabilitySelectionOptions]); - - const capabilityOrder = useMemo(() => { - const order = new Map(); - capabilitySelectionOptions.forEach((option, index) => { - order.set(option.value, index); - }); - return order; - }, [capabilitySelectionOptions]); - - const sortCapabilityValues = useCallback((values) => { - if (!Array.isArray(values)) { - return []; - } - return [...values].sort((a, b) => { - const indexA = capabilityOrder.has(a) ? capabilityOrder.get(a) : Number.MAX_SAFE_INTEGER; - const indexB = capabilityOrder.has(b) ? capabilityOrder.get(b) : Number.MAX_SAFE_INTEGER; - if (indexA === indexB) { - return String(a).localeCompare(String(b)); - } - return indexA - indexB; - }); - }, [capabilityOrder]); - - const formatCapabilityLabel = useCallback((value) => ( - capabilityLabelMap.get(value) || String(value) - ), [capabilityLabelMap]); - - const selectedTokenCapabilitySetCapabilities = useMemo( - () => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [], - [capabilitySetMap, newTokenCapabilitySetId], - ); - - useEffect(() => { - if (!capabilitySetOptions.length) { - setNewTokenCapabilitySetId(''); - return; - } - if (!newTokenCapabilitySetId - || !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) { - setNewTokenCapabilitySetId(capabilitySetOptions[0].value); - } - }, [capabilitySetOptions, newTokenCapabilitySetId]); - - const handleNewCapabilitySetChange = useCallback((event) => { - setFormError(null); - setNewTokenCapabilitySetId(event.target.value); - }, []); - - const handleCapabilitySetsRefresh = useCallback(() => { - if (onRefreshCapabilitySets) { - onRefreshCapabilitySets(); - } else { - onRefresh?.(); - } - onRefreshCapabilities?.(); - }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); - - const handleAddCapabilityToNewSet = useCallback((option) => { - const value = option?.value ?? option?.id ?? option; - if (!value) { - return; - } - setCapabilitySetFormError(null); - setNewCapabilitySetCapabilities((previous) => { - if (previous.includes(value)) { - return previous; - } - return sortCapabilityValues([...previous, value]); - }); - }, [sortCapabilityValues]); - - const handleRemoveCapabilityFromNewSet = useCallback((value) => { - setCapabilitySetFormError(null); - setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); - }, []); - - const handleCreateCapabilitySetSubmit = useCallback( - async (event) => { - event.preventDefault(); - setCapabilitySetFormError(null); - - if (!Array.isArray(newCapabilitySetCapabilities) || newCapabilitySetCapabilities.length === 0) { - setCapabilitySetFormError('Select at least one capability.'); - return; - } - - if (!capabilitySelectionOptions.length) { - setCapabilitySetFormError('Capabilities are still loading.'); - return; - } - - const payload = { - slug: newCapabilitySetSlug, - label: newCapabilitySetLabel, - capabilities: sortCapabilityValues(newCapabilitySetCapabilities), - }; - - const result = await onCreateCapabilitySet?.(payload); - if (result === false) { - setCapabilitySetFormError('Failed to create capability set.'); - return; - } - - setNewCapabilitySetSlug(''); - setNewCapabilitySetLabel(''); - setNewCapabilitySetCapabilities([]); - setCapabilitySetFormError(null); - }, - [ - capabilitySelectionOptions, - newCapabilitySetCapabilities, - newCapabilitySetLabel, - newCapabilitySetSlug, - onCreateCapabilitySet, - sortCapabilityValues, - ], - ); - - const handleStartEditCapabilitySet = useCallback((capabilitySet) => { - if (!capabilitySet) { - return; - } - setCapabilitySetEditError(null); - setEditingCapabilitySetId(capabilitySet.id); - setEditCapabilitySetSlug(capabilitySet.slug || ''); - setEditCapabilitySetLabel(capabilitySet.label || ''); - setEditCapabilitySetCapabilities( - sortCapabilityValues(Array.isArray(capabilitySet.capabilities) ? capabilitySet.capabilities : []), - ); - }, [sortCapabilityValues]); - - const handleCancelEditCapabilitySet = useCallback(() => { - setEditingCapabilitySetId(null); - setEditCapabilitySetSlug(''); - setEditCapabilitySetLabel(''); - setEditCapabilitySetCapabilities([]); - setCapabilitySetEditError(null); - }, []); - - const handleAddCapabilityToEditSet = useCallback((option) => { - const value = option?.value ?? option?.id ?? option; - if (!value) { - return; - } - setCapabilitySetEditError(null); - setEditCapabilitySetCapabilities((previous) => { - if (previous.includes(value)) { - return previous; - } - return sortCapabilityValues([...previous, value]); - }); - }, [sortCapabilityValues]); - - const handleRemoveCapabilityFromEditSet = useCallback((value) => { - setCapabilitySetEditError(null); - setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); - }, []); - - const handleUpdateCapabilitySetSubmit = useCallback( - async (event) => { - event.preventDefault(); - if (!editingCapabilitySetId) { - return; - } - if (!Array.isArray(editCapabilitySetCapabilities) || editCapabilitySetCapabilities.length === 0) { - setCapabilitySetEditError('Select at least one capability.'); - return; - } - - const payload = { - slug: editCapabilitySetSlug, - label: editCapabilitySetLabel, - capabilities: sortCapabilityValues(editCapabilitySetCapabilities), - }; - - const result = await onUpdateCapabilitySet?.(editingCapabilitySetId, payload); - if (result === false) { - setCapabilitySetEditError('Failed to update capability set.'); - return; - } - - handleCancelEditCapabilitySet(); - }, - [ - editCapabilitySetCapabilities, - editCapabilitySetLabel, - editCapabilitySetSlug, - editingCapabilitySetId, - handleCancelEditCapabilitySet, - onUpdateCapabilitySet, - sortCapabilityValues, - ], - ); - - const handleDeleteCapabilitySet = useCallback( - async (capabilitySet) => { - if (!capabilitySet?.id) { - return; - } - const displayName = capabilitySet.slug || capabilitySet.label || capabilitySet.id; - const confirmed = window.confirm( - `Delete capability set "${displayName}"?`, - ); - if (!confirmed) { - return; - } - const result = await onDeleteCapabilitySet?.(capabilitySet.id); - if (result === false) { - setCapabilitySetEditError('Failed to delete capability set.'); - } - }, - [onDeleteCapabilitySet], - ); - - useEffect(() => { - if (!editingCapabilitySetId) { - return; - } - const exists = capabilitySets.some((set) => set.id === editingCapabilitySetId); - if (!exists) { - handleCancelEditCapabilitySet(); - } - }, [capabilitySets, editingCapabilitySetId, handleCancelEditCapabilitySet]); - - const handleCreateToken = useCallback( - async (event) => { - event.preventDefault(); - setFormError(null); - let normalizedLabel = newTokenLabel.trim(); - if (normalizedLabel.length === 0) { - normalizedLabel = undefined; - } - - let normalizedExpires; - if (newTokenExpires) { - const parsed = new Date(newTokenExpires); - if (Number.isNaN(parsed.getTime())) { - setFormError('Enter a valid expiration date.'); - return; - } - normalizedExpires = parsed.toISOString(); - } - - if (!capabilitySetOptions.length) { - setFormError('Capability sets are still loading.'); - return; - } - - const selectedCapabilitySetId = newTokenCapabilitySetId - || capabilitySetOptions[0]?.value; - if (!selectedCapabilitySetId) { - setFormError('Select a capability set.'); - return; - } - - const result = await onCreateToken?.({ - label: normalizedLabel, - expires_at: normalizedExpires, - capability_set_id: selectedCapabilitySetId, - }); - - if (result !== false) { - setNewTokenLabel(''); - setNewTokenExpires(''); - setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || ''); - setFormError(null); - } - }, - [ - capabilitySetOptions, - newTokenCapabilitySetId, - newTokenExpires, - newTokenLabel, - onCreateToken, - ], - ); - - const handleRegenerateToken = useCallback( - async (token) => { - if (!token?.id) { - return; - } - await onRegenerateToken?.(token.id); - }, - [onRegenerateToken], - ); - - const renderCapabilitySetsSection = useMemo(() => { - const hasCapabilitySets = Array.isArray(capabilitySets) && capabilitySets.length > 0; - const columnCount = supportsCapabilitySetLabels ? 6 : 5; - - return ( -
-
- -
- -

- Capability sets bundle permissions that you can assign to API tokens and user memberships. -

- -
-
- - setNewCapabilitySetSlug(event.target.value)} - placeholder="e.g. api_readonly" - disabled={creatingCapabilitySet || capabilitySetsLoading} - /> - Leave blank to generate a slug automatically. -
- {supportsCapabilitySetLabels ? ( -
- - setNewCapabilitySetLabel(event.target.value)} - placeholder="Friendly name (optional)" - disabled={creatingCapabilitySet || capabilitySetsLoading} - /> -
- ) : null} -
- -
- - {newCapabilitySetCapabilities.length ? ( -
- {newCapabilitySetCapabilities.map((value) => ( - - {formatCapabilityLabel(value)} - - - ))} -
- ) : ( - No capabilities selected. - )} - {capabilitiesLoading ? ( - Loading capabilities… - ) : null} - {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( - No capabilities available. - ) : null} -
-
-
- -
-
- {capabilitySetFormError ? ( -

{capabilitySetFormError}

- ) : null} - {capabilitySetsLoading && !hasCapabilitySets ? ( -

Loading capability sets…

- ) : null} - - {!capabilitySetsLoading && !hasCapabilitySets ? ( -

No capability sets yet.

- ) : null} - - {hasCapabilitySets ? ( - - - - - {supportsCapabilitySetLabels ? : null} - - - - - - - - {capabilitySets.map((set) => { - const isSystem = Boolean(set?.is_system); - const isEditing = editingCapabilitySetId === set.id; - const capabilityList = sortCapabilityValues( - Array.isArray(set?.capabilities) ? set.capabilities : [], - ); - const saving = savingCapabilitySetId === set.id; - const deleting = deletingCapabilitySetId === set.id; - - return ( - - - - {supportsCapabilitySetLabels ? ( - - ) : null} - - - - - - {isEditing ? ( - - - - ) : null} - - ); - })} - -
SlugLabelCapabilitiesSystemVersionActions
{set.slug || '—'}{set.label || '—'} - {capabilityList.length - ? capabilityList.map((value) => formatCapabilityLabel(value)).join(', ') - : '—'} - {isSystem ? 'Yes' : 'No'}{typeof set.cap_version === 'number' ? set.cap_version : '—'} - {isSystem ? ( - System set - ) : ( - <> - - - - )} -
-
-
- - setEditCapabilitySetSlug(event.target.value)} - disabled={saving || capabilitySetsLoading} - /> -
- {supportsCapabilitySetLabels ? ( -
- - setEditCapabilitySetLabel(event.target.value)} - disabled={saving || capabilitySetsLoading} - /> -
- ) : null} -
- -
- - {editCapabilitySetCapabilities.length ? ( -
- {editCapabilitySetCapabilities.map((value) => ( - - {formatCapabilityLabel(value)} - - - ))} -
- ) : ( - - No capabilities selected. - - )} - {capabilitiesLoading ? ( - Loading capabilities… - ) : null} - {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( - No capabilities available. - ) : null} -
-
-
- - -
-
- {capabilitySetEditError ? ( -

{capabilitySetEditError}

- ) : null} -
- ) : null} -
- ); - }, [ - capabilitySetEditError, - capabilitySetFormError, - capabilitySelectionOptions, - capabilitiesLoading, - capabilitySets, - capabilitySetsLoading, - creatingCapabilitySet, - deletingCapabilitySetId, - editCapabilitySetCapabilities, - editCapabilitySetLabel, - editCapabilitySetSlug, - editingCapabilitySetId, - handleCapabilitySetsRefresh, - handleAddCapabilityToEditSet, - handleAddCapabilityToNewSet, - handleCancelEditCapabilitySet, - handleCreateCapabilitySetSubmit, - handleDeleteCapabilitySet, - handleRemoveCapabilityFromEditSet, - handleRemoveCapabilityFromNewSet, - handleStartEditCapabilitySet, - handleUpdateCapabilitySetSubmit, - newCapabilitySetCapabilities, - newCapabilitySetLabel, - newCapabilitySetSlug, - formatCapabilityLabel, - sortCapabilityValues, - savingCapabilitySetId, - supportsCapabilitySetLabels, - ]); - - const renderApiTokensSection = useMemo(() => { - const hasTokens = Array.isArray(tokens) && tokens.length > 0; - - return ( -
-
- -
- -

- API tokens use predefined capability sets. Choose the set that matches the access you - need when creating or updating a token. -

- - {createdToken ? ( -
-

- Copy this token now; you will not be able to view it again after closing this window. -

-
{createdToken}
-
- - -
-
- ) : null} - -
-
- - setNewTokenLabel(event.target.value)} - placeholder="Personal API token" - /> -
-
- - setNewTokenExpires(event.target.value)} - /> -
-
- - - {capabilitySetsLoading ? ( - Loading capability sets… - ) : null} - {!capabilitySetsLoading && !capabilitySetOptions.length ? ( - No capability sets available. - ) : null} - {!capabilitySetsLoading && selectedTokenCapabilitySetCapabilities.length ? ( -
- {selectedTokenCapabilitySetCapabilities.map((value) => ( - - {formatCapabilityLabel(value)} - - ))} -
- ) : null} -
-
- -
-
- {formError ?

{formError}

: null} - - {loading && !hasTokens ? ( -

Loading tokens…

- ) : null} - - {!loading && !hasTokens ? ( -

No API tokens yet.

- ) : null} - - {hasTokens ? ( - - - - - - - - - - - - - {tokens.map((token) => { - const isRevoked = Boolean(token?.revoked_at); - const selectedSet = token?.capability_set_id - ? capabilitySetMap[token.capability_set_id] - : null; - const capabilityList = sortCapabilityValues( - Array.isArray(token?.capabilities) && token.capabilities.length - ? token.capabilities - : selectedSet?.capabilities || [], - ); - const capabilitySetLabel = selectedSet?.label - || selectedSet?.slug - || token.capability_set_id - || '—'; - return ( - - - - - - - - - ); - })} - -
LabelCreatedLast usedExpiresCapability setActions
{token.label || '—'}{formatDateTime(token.created_at)}{formatDateTime(token.last_used_at)}{formatDateTime(token.expires_at)} -
- {capabilitySetLabel} -
- {capabilitySetsLoading ? ( - Loading capability sets… - ) : null} - {!capabilitySetsLoading && capabilityList.length ? ( - - {capabilityList.length} capabilities - - ) : null} -
- {isRevoked ? ( - Revoked - ) : ( - <> - - - - )} -
- ) : null} -
- ); - }, [ - tokens, - loading, - createdToken, - creating, - deletingId, - regeneratingId, - newTokenLabel, - newTokenExpires, - newTokenCapabilitySetId, - formError, - capabilitySetOptions, - capabilitySetMap, - capabilitySetsLoading, - sortCapabilityValues, - selectedTokenCapabilitySetCapabilities, - formatCapabilityLabel, - formatDateTime, - handleCopyToken, - handleCreateToken, - handleRegenerateToken, - handleRefresh, - onDeleteToken, - handleDismissSecret, - handleNewCapabilitySetChange, - ]); - - const renderPasskeysSection = useMemo(() => { - const hasPasskeys = Array.isArray(passkeys) && passkeys.length > 0; - - return ( -
-
- -
- - {passkeysSupported === false ? ( -

Passkeys are not enabled for this account.

- ) : ( - <> -
-
- - setNewPasskeyNickname(event.target.value)} - disabled={registeringPasskey} - /> -
-
- -
-
- - {passkeysLoading && !hasPasskeys ? ( -

Loading passkeys…

- ) : null} - - {!passkeysLoading && !hasPasskeys ? ( -

No passkeys registered yet.

- ) : null} - - {hasPasskeys ? ( - - - - - - - - - - - - - {passkeys.map((passkey) => { - const createdAt = passkey.created_at || passkey.createdAt; - const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt; - const revokedAt = passkey.revoked_at || passkey.revokedAt; - const revokedReason = passkey.revoked_reason || passkey.revokedReason; - const revoked = Boolean(revokedAt); - const transports = Array.isArray(passkey.transports) - ? passkey.transports.filter(Boolean) - : []; - - return ( - - - - - - - - - ); - })} - -
NicknameCreatedLast usedTransportsStatusActions
{passkey.nickname || '—'}{formatDateTime(createdAt)}{formatDateTime(lastUsedAt)}{transports.length ? transports.join(', ') : '—'} - {revoked - ? revokedReason - ? `Revoked (${revokedReason})` - : 'Revoked' - : 'Active'} - - {revoked ? ( - Revoked - ) : ( - - )} -
- ) : null} - - )} -
- ); - }, [ - passkeys, - passkeysLoading, - passkeysSupported, - registeringPasskey, - revokingPasskeyId, - newPasskeyNickname, - formatDateTime, - handlePasskeyRefresh, - handlePasskeyRegister, - handlePasskeyRevoke, - ]); - if (!open) { return null; } + const activeSectionConfig = sectionList.find((section) => section.id === activeSection); + let sectionContent = null; + if (activeSectionConfig) { + if (activeSectionConfig.component) { + const SectionComponent = activeSectionConfig.component; + sectionContent = ; + } else if (typeof activeSectionConfig.render === 'function') { + sectionContent = activeSectionConfig.render(sectionProps); + } + } + return (
- {activeSection === 'passkeys' ? renderPasskeysSection : null} - {activeSection === 'capabilitySets' ? renderCapabilitySetsSection : null} - {activeSection === 'apiTokens' ? renderApiTokensSection : null} - {activeSection !== 'passkeys' - && activeSection !== 'capabilitySets' - && activeSection !== 'apiTokens' ? ( + {sectionContent || (

Select a settings section.

- ) : null} + )}
diff --git a/frontend/src/settings/components/CapabilityDropdown.jsx b/frontend/src/settings/components/CapabilityDropdown.jsx new file mode 100644 index 0000000..30e6e14 --- /dev/null +++ b/frontend/src/settings/components/CapabilityDropdown.jsx @@ -0,0 +1,147 @@ +import React, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { CheckIcon, ChevronDownIcon } from '../../ui/icons'; + +const CapabilityDropdown = ({ + id, + options = [], + selectedValues = [], + onSelect, + onDeselect, + formatLabel, + disabled = false, + loading = false, + summaryLabel = 'capabilities', +}) => { + const anchorRef = useRef(null); + const menuRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + const toggle = useCallback(() => { + if (disabled) { + return; + } + setIsOpen((previous) => !previous); + }, [disabled]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + const handlePointerEvent = (event) => { + if (anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) { + return; + } + close(); + }; + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + close(); + } + }; + + document.addEventListener('mousedown', handlePointerEvent); + document.addEventListener('touchstart', handlePointerEvent, { passive: true }); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handlePointerEvent); + document.removeEventListener('touchstart', handlePointerEvent); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [close, isOpen]); + + const handleOptionClick = useCallback((value) => { + if (selectedValues.includes(value)) { + onDeselect?.(value); + } else { + onSelect?.(value); + } + }, [onDeselect, onSelect, selectedValues]); + + const total = options.length; + const selectedCount = selectedValues.length; + + const summaryText = total + ? `${selectedCount}/${total} ${summaryLabel} enabled` + : selectedCount + ? `${selectedCount} ${summaryLabel} selected` + : loading + ? `Loading ${summaryLabel}…` + : `No ${summaryLabel}`; + + const buttonText = selectedCount || loading || total + ? summaryText + : `Select ${summaryLabel}`; + + const emptyMessage = loading + ? `Loading ${summaryLabel}…` + : `No ${summaryLabel} available.`; + + const isDisabled = disabled || (total === 0 && !selectedCount) || loading; + + return ( +
+ + {isOpen ? ( + + ) : null} +
+ ); +}; + +export default CapabilityDropdown; diff --git a/frontend/src/settings/sections/ApiTokensSection.jsx b/frontend/src/settings/sections/ApiTokensSection.jsx new file mode 100644 index 0000000..57bec67 --- /dev/null +++ b/frontend/src/settings/sections/ApiTokensSection.jsx @@ -0,0 +1,407 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +const formatDateTime = (value) => { + if (!value) { + return '—'; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return value; + } + return timestamp.toLocaleString(); +}; + +const ApiTokensSection = ({ + tokens = [], + loading = false, + creating = false, + deletingId = null, + regeneratingId = null, + createdToken = null, + capabilitySets = [], + capabilitySetsLoading = false, + capabilities = [], + capabilitiesLoading = false, + onRefresh, + onCreate, + onDelete, + onRegenerate, + onDismissCreatedToken, + onRefreshCapabilitySets, + onRefreshCapabilities, +}) => { + const [newTokenLabel, setNewTokenLabel] = useState(''); + const [newTokenExpires, setNewTokenExpires] = useState(''); + const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState(''); + const [formError, setFormError] = useState(null); + + const capabilitySetOptions = useMemo( + () => capabilitySets.map((set) => ({ + value: set.id, + label: set.label || set.slug || set.id, + capabilities: Array.isArray(set.capabilities) ? set.capabilities : [], + })), + [capabilitySets], + ); + + const capabilitySetMap = useMemo( + () => Object.fromEntries(capabilitySetOptions.map((option) => [option.value, option])), + [capabilitySetOptions], + ); + + const capabilitySelectionOptions = useMemo(() => ( + Array.isArray(capabilities) + ? capabilities.map((capability) => { + if (typeof capability !== 'string') { + return { value: capability, label: String(capability) }; + } + const [namespace, action] = capability.split(':'); + if (!namespace || !action) { + return { value: capability, label: capability }; + } + const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; + const formattedAction = action.replace(/_/g, ' '); + return { + value: capability, + label: `${formattedNamespace}: ${formattedAction}`, + }; + }) + : [] + ), [capabilities]); + + const capabilityLabelMap = useMemo(() => { + const map = new Map(); + capabilitySelectionOptions.forEach(({ value, label }) => { + map.set(value, label || String(value)); + }); + return map; + }, [capabilitySelectionOptions]); + + const formatCapabilityLabel = useCallback((value) => ( + capabilityLabelMap.get(value) || String(value) + ), [capabilityLabelMap]); + + useEffect(() => { + if (!capabilitySetOptions.length) { + setNewTokenCapabilitySetId(''); + return; + } + if (!newTokenCapabilitySetId + || !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) { + setNewTokenCapabilitySetId(capabilitySetOptions[0].value); + } + }, [capabilitySetOptions, newTokenCapabilitySetId]); + + const selectedTokenCapabilitySetCapabilities = useMemo( + () => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [], + [capabilitySetMap, newTokenCapabilitySetId], + ); + + const handleRefresh = useCallback(() => { + if (onRefresh) { + onRefresh(); + } + onRefreshCapabilitySets?.(); + onRefreshCapabilities?.(); + }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); + + const handleCopyToken = useCallback(() => { + if (!createdToken) { + return; + } + if (navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(createdToken).catch(() => {}); + } + }, [createdToken]); + + const handleDismissSecret = useCallback(() => { + onDismissCreatedToken?.(); + }, [onDismissCreatedToken]); + + const handleNewCapabilitySetChange = useCallback((event) => { + setFormError(null); + setNewTokenCapabilitySetId(event.target.value); + }, []); + + const handleCreateToken = useCallback( + async (event) => { + event.preventDefault(); + setFormError(null); + let normalizedLabel = newTokenLabel.trim(); + if (normalizedLabel.length === 0) { + normalizedLabel = undefined; + } + + let normalizedExpires; + if (newTokenExpires) { + const parsed = new Date(newTokenExpires); + if (Number.isNaN(parsed.getTime())) { + setFormError('Enter a valid expiration date.'); + return; + } + normalizedExpires = parsed.toISOString(); + } + + if (!capabilitySetOptions.length) { + setFormError('Capability sets are still loading.'); + return; + } + + const selectedCapabilitySetId = newTokenCapabilitySetId || capabilitySetOptions[0]?.value; + if (!selectedCapabilitySetId) { + setFormError('Select a capability set.'); + return; + } + + const result = await onCreate?.({ + label: normalizedLabel, + expires_at: normalizedExpires, + capability_set_id: selectedCapabilitySetId, + }); + + if (result !== false) { + setNewTokenLabel(''); + setNewTokenExpires(''); + setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || ''); + setFormError(null); + } + }, + [ + capabilitySetOptions, + newTokenCapabilitySetId, + newTokenExpires, + newTokenLabel, + onCreate, + ], + ); + + const handleRegenerateToken = useCallback( + async (token) => { + if (!token?.id) { + return; + } + await onRegenerate?.(token.id); + }, + [onRegenerate], + ); + + return ( +
+
+ +
+ +

+ API tokens use predefined capability sets. Choose the set that matches the access you need when + creating or updating a token. +

+ + {createdToken ? ( +
+

+ Copy this token now; you will not be able to view it again after closing this window. +

+
{createdToken}
+
+ + +
+
+ ) : null} + +
+
+ + setNewTokenLabel(event.target.value)} + placeholder="Personal API token" + /> +
+
+ + setNewTokenExpires(event.target.value)} + /> +
+
+ + + {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} + {!capabilitySetsLoading && !capabilitySetOptions.length ? ( + No capability sets available yet. + ) : null} +
+
+ {selectedTokenCapabilitySetCapabilities.length ? ( +
+ {selectedTokenCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + ))} +
+ ) : ( + No capabilities selected. + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} +
+
+ +
+
+ {formError ? ( +

{formError}

+ ) : null} + + {loading && !tokens.length ? ( +

Loading tokens…

+ ) : null} + + {!loading && !tokens.length ? ( +

No API tokens yet.

+ ) : null} + + {tokens.length ? ( + + + + + + + + + + + + + {tokens.map((token) => { + const isRevoked = Boolean(token?.revoked_at); + const selectedSet = token?.capability_set_id + ? capabilitySetMap[token.capability_set_id] + : null; + const capabilityList = Array.isArray(token?.capabilities) && token.capabilities.length + ? token.capabilities + : selectedSet?.capabilities || []; + const capabilitySetLabel = selectedSet?.label + || selectedSet?.slug + || token.capability_set_id + || '—'; + + return ( + + + + + + + + + ); + })} + +
LabelCreatedLast usedExpiresCapability setActions
{token.label || '—'}{formatDateTime(token.created_at)}{formatDateTime(token.last_used_at)}{formatDateTime(token.expires_at)} +
+ {capabilitySetLabel} +
+ {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} + {!capabilitySetsLoading && capabilityList.length ? ( +
+ {capabilityList.map((value) => ( + + {formatCapabilityLabel(value)} + + ))} +
+ ) : null} +
+ {isRevoked ? ( + Revoked + ) : ( + <> + + + + )} +
+ ) : null} +
+ ); +}; + +export const API_TOKENS_SECTION = { + id: 'apiTokens', + label: 'API tokens', + component: ApiTokensSection, +}; + +export default ApiTokensSection; diff --git a/frontend/src/settings/sections/CapabilitySetsSection.jsx b/frontend/src/settings/sections/CapabilitySetsSection.jsx new file mode 100644 index 0000000..27ee405 --- /dev/null +++ b/frontend/src/settings/sections/CapabilitySetsSection.jsx @@ -0,0 +1,582 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { IconX } from '../../ui/icons'; +import CapabilityDropdown from '../components/CapabilityDropdown'; + +const CapabilitySetsSection = ({ + capabilitySets = [], + capabilitySetsLoading = false, + creatingCapabilitySet = false, + savingCapabilitySetId = null, + deletingCapabilitySetId = null, + supportsCapabilitySetLabels = false, + capabilities = [], + capabilitiesLoading = false, + onRefreshCapabilitySets, + onRefreshCapabilities, + onRefresh, + onCreateCapabilitySet, + onUpdateCapabilitySet, + onDeleteCapabilitySet, +}) => { + const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState(''); + const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState(''); + const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]); + const [capabilitySetFormError, setCapabilitySetFormError] = useState(null); + + const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null); + const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState(''); + const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState(''); + const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]); + const [capabilitySetEditError, setCapabilitySetEditError] = useState(null); + + const capabilitySelectionOptions = useMemo(() => ( + Array.isArray(capabilities) + ? capabilities.map((capability) => { + if (typeof capability !== 'string') { + return { value: capability, label: String(capability) }; + } + const [namespace, action] = capability.split(':'); + if (!namespace || !action) { + return { value: capability, label: capability }; + } + const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; + const formattedAction = action.replace(/_/g, ' '); + return { + value: capability, + label: `${formattedNamespace}: ${formattedAction}`, + }; + }) + : [] + ), [capabilities]); + + const capabilityLabelMap = useMemo(() => { + const map = new Map(); + capabilitySelectionOptions.forEach(({ value, label }) => { + map.set(value, label || String(value)); + }); + return map; + }, [capabilitySelectionOptions]); + + const capabilityOrder = useMemo(() => { + const order = new Map(); + capabilitySelectionOptions.forEach((option, index) => { + order.set(option.value, index); + }); + return order; + }, [capabilitySelectionOptions]); + + const sortCapabilityValues = useCallback((values) => { + if (!Array.isArray(values)) { + return []; + } + return [...values].sort((a, b) => { + const indexA = capabilityOrder.has(a) ? capabilityOrder.get(a) : Number.MAX_SAFE_INTEGER; + const indexB = capabilityOrder.has(b) ? capabilityOrder.get(b) : Number.MAX_SAFE_INTEGER; + if (indexA === indexB) { + return String(a).localeCompare(String(b)); + } + return indexA - indexB; + }); + }, [capabilityOrder]); + + const formatCapabilityLabel = useCallback((value) => ( + capabilityLabelMap.get(value) || String(value) + ), [capabilityLabelMap]); + + const capabilitySetOptions = useMemo( + () => capabilitySets.map((set) => ({ + value: set.id, + label: set.label || set.slug || set.id, + capabilities: Array.isArray(set.capabilities) ? set.capabilities : [], + isSystem: Boolean(set?.is_system), + version: typeof set?.cap_version === 'number' ? set.cap_version : null, + })), + [capabilitySets], + ); + + const hasCapabilitySets = capabilitySetOptions.length > 0; + const columnCount = supportsCapabilitySetLabels ? 6 : 5; + + const handleCapabilitySetsRefresh = useCallback(() => { + if (onRefreshCapabilitySets) { + onRefreshCapabilitySets(); + } else { + onRefresh?.(); + } + onRefreshCapabilities?.(); + }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); + + const handleAddCapabilityToNewSet = useCallback((option) => { + const value = option?.value ?? option?.id ?? option; + if (!value) { + return; + } + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromNewSet = useCallback((value) => { + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleCreateCapabilitySetSubmit = useCallback( + async (event) => { + event.preventDefault(); + setCapabilitySetFormError(null); + + if (!Array.isArray(newCapabilitySetCapabilities) || newCapabilitySetCapabilities.length === 0) { + setCapabilitySetFormError('Select at least one capability.'); + return; + } + + if (!capabilitySelectionOptions.length) { + setCapabilitySetFormError('Capabilities are still loading.'); + return; + } + + const payload = { + slug: newCapabilitySetSlug, + label: newCapabilitySetLabel, + capabilities: sortCapabilityValues(newCapabilitySetCapabilities), + }; + + const result = await onCreateCapabilitySet?.(payload); + if (result === false) { + setCapabilitySetFormError('Failed to create capability set.'); + return; + } + + setNewCapabilitySetSlug(''); + setNewCapabilitySetLabel(''); + setNewCapabilitySetCapabilities([]); + setCapabilitySetFormError(null); + }, + [ + capabilitySelectionOptions, + newCapabilitySetCapabilities, + newCapabilitySetLabel, + newCapabilitySetSlug, + onCreateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleStartEditCapabilitySet = useCallback((capabilitySet) => { + if (!capabilitySet) { + return; + } + setCapabilitySetEditError(null); + setEditingCapabilitySetId(capabilitySet.id); + setEditCapabilitySetSlug(capabilitySet.slug || ''); + setEditCapabilitySetLabel(capabilitySet.label || ''); + setEditCapabilitySetCapabilities( + sortCapabilityValues(Array.isArray(capabilitySet.capabilities) ? capabilitySet.capabilities : []), + ); + }, [sortCapabilityValues]); + + const handleCancelEditCapabilitySet = useCallback(() => { + setEditingCapabilitySetId(null); + setEditCapabilitySetSlug(''); + setEditCapabilitySetLabel(''); + setEditCapabilitySetCapabilities([]); + setCapabilitySetEditError(null); + }, []); + + const handleAddCapabilityToEditSet = useCallback((option) => { + const value = option?.value ?? option?.id ?? option; + if (!value) { + return; + } + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromEditSet = useCallback((value) => { + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleUpdateCapabilitySetSubmit = useCallback( + async (event) => { + event.preventDefault(); + if (!editingCapabilitySetId) { + return; + } + if (!Array.isArray(editCapabilitySetCapabilities) || editCapabilitySetCapabilities.length === 0) { + setCapabilitySetEditError('Select at least one capability.'); + return; + } + + const payload = { + slug: editCapabilitySetSlug, + label: editCapabilitySetLabel, + capabilities: sortCapabilityValues(editCapabilitySetCapabilities), + }; + + const result = await onUpdateCapabilitySet?.(editingCapabilitySetId, payload); + if (result === false) { + setCapabilitySetEditError('Failed to update capability set.'); + return; + } + + handleCancelEditCapabilitySet(); + }, + [ + editCapabilitySetCapabilities, + editCapabilitySetLabel, + editCapabilitySetSlug, + editingCapabilitySetId, + handleCancelEditCapabilitySet, + onUpdateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleDeleteCapabilitySet = useCallback( + async (capabilitySet) => { + if (!capabilitySet?.id) { + return; + } + const displayName = capabilitySet.slug || capabilitySet.label || capabilitySet.id; + const confirmed = window.confirm(`Delete capability set "${displayName}"?`); + if (!confirmed) { + return; + } + const result = await onDeleteCapabilitySet?.(capabilitySet.id); + if (result === false) { + setCapabilitySetEditError('Failed to delete capability set.'); + } + }, + [onDeleteCapabilitySet], + ); + + useEffect(() => { + if (!editingCapabilitySetId) { + return; + } + const exists = capabilitySets.some((set) => set.id === editingCapabilitySetId); + if (!exists) { + handleCancelEditCapabilitySet(); + } + }, [capabilitySets, editingCapabilitySetId, handleCancelEditCapabilitySet]); + + return ( +
+
+ +
+ +

+ Capability sets bundle permissions that you can assign to API tokens and user memberships. +

+ +
+
+ + setNewCapabilitySetSlug(event.target.value)} + placeholder="e.g. api_readonly" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> + Leave blank to generate a slug automatically. +
+ {supportsCapabilitySetLabels ? ( +
+ + setNewCapabilitySetLabel(event.target.value)} + placeholder="Friendly name (optional)" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {newCapabilitySetCapabilities.length ? ( +
+ {newCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + No capabilities selected. + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ +
+
+ {capabilitySetFormError ? ( +

{capabilitySetFormError}

+ ) : null} + {capabilitySetsLoading && !hasCapabilitySets ? ( +

Loading capability sets…

+ ) : null} + + {!capabilitySetsLoading && !hasCapabilitySets ? ( +

No capability sets yet.

+ ) : null} + + {hasCapabilitySets ? ( + + + + + {supportsCapabilitySetLabels ? : null} + + + + + + + + {capabilitySets.map((set) => { + const isSystem = Boolean(set?.is_system); + const isEditing = editingCapabilitySetId === set.id; + const capabilityList = sortCapabilityValues( + Array.isArray(set?.capabilities) ? set.capabilities : [], + ); + const saving = savingCapabilitySetId === set.id; + const deleting = deletingCapabilitySetId === set.id; + + return ( + + + + {supportsCapabilitySetLabels ? ( + + ) : null} + + + + + + {isEditing ? ( + + + + ) : null} + + ); + })} + +
SlugLabelCapabilitiesSystemVersionActions
{set.slug || '—'}{set.label || '—'} + {capabilityList.length + ? capabilityList.map((value) => formatCapabilityLabel(value)).join(', ') + : '—'} + {isSystem ? 'Yes' : 'No'}{typeof set.cap_version === 'number' ? set.cap_version : '—'} + {isSystem ? ( + System set + ) : ( + <> + + + + )} +
+
+
+ + setEditCapabilitySetSlug(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ {supportsCapabilitySetLabels ? ( +
+ + setEditCapabilitySetLabel(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {editCapabilitySetCapabilities.length ? ( +
+ {editCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + + No capabilities selected. + + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ + +
+
+ {capabilitySetEditError ? ( +

{capabilitySetEditError}

+ ) : null} +
+ ) : null} +
+ ); +}; + +export const CAPABILITY_SETS_SECTION = { + id: 'capabilitySets', + label: 'Capability sets', + component: CapabilitySetsSection, +}; + +export default CapabilitySetsSection; diff --git a/frontend/src/settings/sections/PasskeysSection.jsx b/frontend/src/settings/sections/PasskeysSection.jsx new file mode 100644 index 0000000..143254c --- /dev/null +++ b/frontend/src/settings/sections/PasskeysSection.jsx @@ -0,0 +1,172 @@ +import React, { + useCallback, + useMemo, + useState, +} from 'react'; + +const formatDateTime = (value) => { + if (!value) { + return '—'; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return value; + } + return timestamp.toLocaleString(); +}; + +const PasskeysSection = ({ + passkeys = [], + passkeysSupported = null, + passkeysLoading = false, + registeringPasskey = false, + revokingPasskeyId = null, + onRefreshPasskeys, + onRegisterPasskey, + onRevokePasskey, +}) => { + const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); + + const hasPasskeys = useMemo(() => Array.isArray(passkeys) && passkeys.length > 0, [passkeys]); + + const handlePasskeyRefresh = useCallback(() => { + onRefreshPasskeys?.(); + }, [onRefreshPasskeys]); + + const handlePasskeyRegister = useCallback( + async (event) => { + event.preventDefault(); + const nickname = newPasskeyNickname.trim(); + const result = await onRegisterPasskey?.({ nickname }); + if (result?.ok) { + setNewPasskeyNickname(''); + } + }, + [newPasskeyNickname, onRegisterPasskey], + ); + + const handlePasskeyRevoke = useCallback( + async (passkey) => { + if (!passkey?.id) { + return; + } + const reasonInput = window.prompt('Optional reason for revoking this passkey:', ''); + const reason = reasonInput ? reasonInput.trim() : undefined; + await onRevokePasskey?.(passkey.id, reason); + }, + [onRevokePasskey], + ); + + return ( +
+
+ +
+ + {passkeysSupported === false ? ( +

Passkeys are not enabled for this account.

+ ) : ( + <> +
+
+ + setNewPasskeyNickname(event.target.value)} + disabled={registeringPasskey} + /> +
+
+ +
+
+ + {passkeysLoading && !hasPasskeys ? ( +

Loading passkeys…

+ ) : null} + + {!passkeysLoading && !hasPasskeys ? ( +

No passkeys registered yet.

+ ) : null} + + {hasPasskeys ? ( + + + + + + + + + + + + + {passkeys.map((passkey) => { + const createdAt = passkey.created_at || passkey.createdAt; + const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt; + const revokedAt = passkey.revoked_at || passkey.revokedAt; + const revokedReason = passkey.revoked_reason || passkey.revokedReason; + const revoked = Boolean(revokedAt); + const transports = Array.isArray(passkey.transports) + ? passkey.transports.filter(Boolean) + : []; + + return ( + + + + + + + + + ); + })} + +
NicknameCreatedLast usedTransportsStatusActions
{passkey.nickname || '—'}{formatDateTime(createdAt)}{formatDateTime(lastUsedAt)}{transports.length ? transports.join(', ') : '—'} + {revoked + ? revokedReason + ? `Revoked (${revokedReason})` + : 'Revoked' + : 'Active'} + + {revoked ? ( + Revoked + ) : ( + + )} +
+ ) : null} + + )} +
+ ); +}; + +export const PASSKEYS_SECTION = { + id: 'passkeys', + label: 'Passkeys', + component: PasskeysSection, +}; + +export default PasskeysSection; diff --git a/frontend/src/settings/sections/index.js b/frontend/src/settings/sections/index.js new file mode 100644 index 0000000..f0bad0d --- /dev/null +++ b/frontend/src/settings/sections/index.js @@ -0,0 +1,15 @@ +import PasskeysSection, { PASSKEYS_SECTION } from './PasskeysSection'; +import ApiTokensSection, { API_TOKENS_SECTION } from './ApiTokensSection'; +import CapabilitySetsSection, { CAPABILITY_SETS_SECTION } from './CapabilitySetsSection'; + +export const DEFAULT_SETTINGS_SECTIONS = [ + PASSKEYS_SECTION, + API_TOKENS_SECTION, + CAPABILITY_SETS_SECTION, +]; + +export { + PasskeysSection, + ApiTokensSection, + CapabilitySetsSection, +}; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 66e166c..535cab4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3723,7 +3723,7 @@ form.inline { } .settings-modal__sidebar button.active { - background: var(--surface-soft); + background: var(--accent-soft); font-weight: 600; } @@ -3906,6 +3906,23 @@ fieldset.settings-form__field legend { font-size: 0.9rem; } +.settings-capability-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.settings-capability-list--compact { + margin-top: 0.2rem; + gap: 0.25rem; +} + +.settings-capability-list__item { + display: inline-flex; + align-items: center; +} + .settings-choice { display: inline-flex; align-items: center;