typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
@@ -1,4 +1,5 @@
import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
@@ -7,18 +8,33 @@ import React, {
import PanelHeader from '../ui/PanelHeader';
import { DEFAULT_SETTINGS_SECTIONS } from './sections';
const SettingsModal = ({
export interface SettingsSectionConfig {
id: string;
label: string;
component?: React.ComponentType<any>;
render?: (props: Record<string, unknown>) => ReactNode;
}
interface SettingsModalProps {
open?: boolean;
onClose?: () => void;
sections?: SettingsSectionConfig[];
defaultSectionId?: string;
[key: string]: unknown;
}
const SettingsModal: React.FC<SettingsModalProps> = ({
open = false,
onClose,
sections,
defaultSectionId,
...sectionProps
}) => {
const sectionList = useMemo(() => {
const sectionList: SettingsSectionConfig[] = useMemo(() => {
if (Array.isArray(sections) && sections.length) {
return sections;
}
return DEFAULT_SETTINGS_SECTIONS;
return DEFAULT_SETTINGS_SECTIONS as SettingsSectionConfig[];
}, [sections]);
const firstSectionId = sectionList[0]?.id ?? null;
@@ -1,11 +1,51 @@
import React, {
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { JSX } from 'react';
import { CheckIcon, ChevronDownIcon } from '../../ui/icons';
type CapabilityValue = string | number;
export interface CapabilityDropdownOption {
value?: CapabilityValue | null;
id?: CapabilityValue | null;
label?: string;
}
interface CapabilityDropdownProps {
id?: string;
options?: Array<CapabilityDropdownOption | CapabilityValue>;
selectedValues?: CapabilityValue[];
onSelect?: (value: CapabilityValue) => void;
onDeselect?: (value: CapabilityValue) => void;
formatLabel?: (value: CapabilityValue) => string;
disabled?: boolean;
loading?: boolean;
summaryLabel?: string;
}
const resolveCapabilityValue = (
option: CapabilityDropdownOption | CapabilityValue | null | undefined,
): CapabilityValue | null => {
if (option == null) {
return null;
}
if (typeof option === 'string' || typeof option === 'number') {
return option;
}
if (option.value != null) {
return option.value;
}
if (option.id != null) {
return option.id;
}
return null;
};
const CapabilityDropdown = ({
id,
options = [],
@@ -16,9 +56,9 @@ const CapabilityDropdown = ({
disabled = false,
loading = false,
summaryLabel = 'capabilities',
}) => {
const anchorRef = useRef(null);
const menuRef = useRef(null);
}: CapabilityDropdownProps): JSX.Element => {
const anchorRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const close = useCallback(() => {
@@ -37,14 +77,17 @@ const CapabilityDropdown = ({
return undefined;
}
const handlePointerEvent = (event) => {
if (anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) {
const handlePointerEvent = (event: MouseEvent | TouchEvent) => {
if (anchorRef.current?.contains(event.target as Node)) {
return;
}
if (menuRef.current?.contains(event.target as Node)) {
return;
}
close();
};
const handleKeyDown = (event) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
@@ -61,7 +104,10 @@ const CapabilityDropdown = ({
};
}, [close, isOpen]);
const handleOptionClick = useCallback((value) => {
const handleOptionClick = useCallback((value: CapabilityValue | null) => {
if (value == null) {
return;
}
if (selectedValues.includes(value)) {
onDeselect?.(value);
} else {
@@ -69,26 +115,27 @@ const CapabilityDropdown = ({
}
}, [onDeselect, onSelect, selectedValues]);
const total = options.length;
const derivedOptions = useMemo(() => options.filter(Boolean), [options]);
const total = derivedOptions.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 summaryText = useMemo(() => {
if (total) {
return `${selectedCount}/${total} ${summaryLabel} enabled`;
}
if (selectedCount) {
return `${selectedCount} ${summaryLabel} selected`;
}
if (loading) {
return `Loading ${summaryLabel}`;
}
return `No ${summaryLabel}`;
}, [loading, selectedCount, summaryLabel, total]);
const buttonText = total || selectedCount || loading ? summaryText : `Select ${summaryLabel}`;
const emptyMessage = loading ? `Loading ${summaryLabel}` : `No ${summaryLabel} available.`;
const isDisabled = disabled || (total === 0 && !selectedCount) || loading;
const menuId = id ? `${id}-menu` : undefined;
return (
<div className="capability-dropdown">
@@ -97,7 +144,7 @@ const CapabilityDropdown = ({
className="capability-dropdown__trigger"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={id ? `${id}-menu` : undefined}
aria-controls={menuId}
onClick={toggle}
disabled={isDisabled}
ref={anchorRef}
@@ -107,18 +154,21 @@ const CapabilityDropdown = ({
</button>
{isOpen ? (
<div
id={id ? `${id}-menu` : undefined}
id={menuId}
role="menu"
ref={menuRef}
className="menu menu--floating capability-dropdown__menu"
data-floating-position
>
{total ? (
options.map((option) => {
const value = option?.value ?? option?.id ?? option;
derivedOptions.map((option) => {
const value = resolveCapabilityValue(option);
if (value == null) {
return null;
}
const label = typeof formatLabel === 'function'
? formatLabel(value)
: option?.label || String(value);
: (typeof option === 'object' && option?.label) || String(value);
const selected = selectedValues.includes(value);
return (
<button
@@ -1,9 +1,66 @@
import React, {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
type Identifier = string | number;
interface ApiTokenEntry {
id?: Identifier;
label?: string;
expires_at?: string;
created_at?: string;
last_used_at?: string;
revoked_at?: string;
revoked_reason?: string;
revokedReason?: string;
transports?: string[];
capability_set_id?: Identifier;
[key: string]: unknown;
}
interface CapabilitySetEntry {
id?: Identifier;
slug?: string;
label?: string;
capabilities?: string[];
}
interface CapabilitySetOption {
value: string;
label: string;
capabilities: string[];
sourceId?: Identifier;
slug?: string;
}
interface CapabilitySelectionOption {
value: string;
label: string;
}
interface CopyFeedbackState {
type: 'success' | 'error';
message: string;
}
interface ApiTokensSectionProps {
tokens?: ApiTokenEntry[];
loading?: boolean;
creating?: boolean;
deletingId?: Identifier | null;
regeneratingId?: Identifier | null;
createdToken?: string | null;
capabilitySets?: CapabilitySetEntry[];
capabilitySetsLoading?: boolean;
capabilities?: string[];
capabilitiesLoading?: boolean;
onRefresh?: () => void;
onCreate?: (payload: { label?: string; expires_at?: string; capability_set_id: Identifier | string }) => Promise<unknown> | unknown;
onDelete?: (tokenId: Identifier) => Promise<unknown> | unknown;
onRegenerate?: (tokenId: Identifier) => Promise<unknown> | unknown;
onDismissCreatedToken?: () => void;
onRefreshCapabilitySets?: () => void;
onRefreshCapabilities?: () => void;
}
const formatDateTime = (value) => {
if (!value) {
@@ -34,34 +91,42 @@ const ApiTokensSection = ({
onDismissCreatedToken,
onRefreshCapabilitySets,
onRefreshCapabilities,
}) => {
}: ApiTokensSectionProps) => {
const [newTokenLabel, setNewTokenLabel] = useState('');
const [newTokenExpires, setNewTokenExpires] = useState('');
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState('');
const [formError, setFormError] = useState(null);
const supportsClipboardWrite = Boolean(navigator?.clipboard?.writeText);
const [canCopyToken, setCanCopyToken] = useState(supportsClipboardWrite);
const [copyFeedback, setCopyFeedback] = useState(null);
const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState<string>('');
const [formError, setFormError] = useState<string | null>(null);
const supportsClipboardWrite = typeof navigator !== 'undefined' && Boolean(navigator?.clipboard?.writeText);
const [canCopyToken, setCanCopyToken] = useState<boolean>(supportsClipboardWrite);
const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackState | null>(null);
const capabilitySetOptions = useMemo(
() => capabilitySets.map((set) => ({
value: set.id,
label: set.label || set.slug || set.id,
capabilities: Array.isArray(set.capabilities) ? set.capabilities : [],
})),
const capabilitySetOptions = useMemo<CapabilitySetOption[]>(
() =>
capabilitySets.map((set) => ({
value: set.id != null ? String(set.id) : set.slug ?? '',
label: `${set.label || set.slug || set.id || 'Capability set'}`,
capabilities: Array.isArray(set.capabilities)
? set.capabilities.map((cap) => String(cap ?? ''))
: [],
sourceId: set.id,
slug: set.slug,
})),
[capabilitySets],
);
const capabilitySetMap = useMemo(
() => Object.fromEntries(capabilitySetOptions.map((option) => [option.value, option])),
const capabilitySetMap = useMemo<Record<string, CapabilitySetOption>>(
() => {
const entries = capabilitySetOptions.map((option) => [option.value, option] as const);
return Object.fromEntries(entries);
},
[capabilitySetOptions],
);
const capabilitySelectionOptions = useMemo(() => (
const capabilitySelectionOptions = useMemo<CapabilitySelectionOption[]>(() => (
Array.isArray(capabilities)
? capabilities.map((capability) => {
if (typeof capability?.split !== 'function') {
return { value: capability, label: String(capability) };
if (typeof capability !== 'string') {
return { value: String(capability ?? ''), label: String(capability ?? '') };
}
const [namespace, action] = capability.split(':');
if (!namespace || !action) {
@@ -78,14 +143,14 @@ const ApiTokensSection = ({
), [capabilities]);
const capabilityLabelMap = useMemo(() => {
const map = new Map();
const map = new Map<string, string>();
capabilitySelectionOptions.forEach(({ value, label }) => {
map.set(value, label || String(value));
});
return map;
}, [capabilitySelectionOptions]);
const formatCapabilityLabel = useCallback((value) => (
const formatCapabilityLabel = useCallback((value: string) => (
capabilityLabelMap.get(value) || String(value)
), [capabilityLabelMap]);
@@ -128,6 +193,9 @@ const ApiTokensSection = ({
});
try {
if (typeof navigator === 'undefined') {
throw new Error('Clipboard API unavailable');
}
await navigator.clipboard.writeText(createdToken);
showSuccess();
return;
@@ -146,16 +214,17 @@ const ApiTokensSection = ({
useEffect(() => {
setCopyFeedback(null);
setCanCopyToken(Boolean(navigator?.clipboard?.writeText));
const hasClipboard = typeof navigator !== 'undefined' && Boolean(navigator?.clipboard?.writeText);
setCanCopyToken(hasClipboard);
}, [createdToken]);
const handleNewCapabilitySetChange = useCallback((event) => {
const handleNewCapabilitySetChange = useCallback((event: ChangeEvent<HTMLSelectElement>) => {
setFormError(null);
setNewTokenCapabilitySetId(event.target.value);
}, []);
const handleCreateToken = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setFormError(null);
let normalizedLabel = newTokenLabel.trim();
@@ -184,10 +253,12 @@ const ApiTokensSection = ({
return;
}
const resolvedCapabilitySetId = capabilitySetMap[selectedCapabilitySetId]?.sourceId ?? selectedCapabilitySetId;
const result = await onCreate?.({
label: normalizedLabel,
expires_at: normalizedExpires,
capability_set_id: selectedCapabilitySetId,
capability_set_id: resolvedCapabilitySetId,
});
if (result !== false) {
@@ -199,6 +270,7 @@ const ApiTokensSection = ({
},
[
capabilitySetOptions,
capabilitySetMap,
newTokenCapabilitySetId,
newTokenExpires,
newTokenLabel,
@@ -207,7 +279,7 @@ const ApiTokensSection = ({
);
const handleRegenerateToken = useCallback(
async (token) => {
async (token: ApiTokenEntry) => {
if (!token?.id) {
return;
}
@@ -359,9 +431,10 @@ const ApiTokensSection = ({
<tbody>
{tokens.map((token) => {
const isRevoked = Boolean(token?.revoked_at);
const selectedSet = token?.capability_set_id
? capabilitySetMap[token.capability_set_id]
const capabilityKey = token?.capability_set_id != null
? String(token.capability_set_id)
: null;
const selectedSet = capabilityKey ? capabilitySetMap[capabilityKey] : null;
const capabilitySetLabel = selectedSet?.label
|| selectedSet?.slug
|| token.capability_set_id
@@ -399,9 +472,10 @@ const ApiTokensSection = ({
<button
type="button"
className="danger"
onClick={() => onDelete?.(token.id)}
onClick={() => token.id && onDelete?.(token.id)}
disabled={
deletingId === token.id || regeneratingId === token.id
|| !token.id
}
>
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
@@ -1,13 +1,76 @@
import React, {
FormEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import type { SettingsSectionConfig } from '../SettingsModal';
import { IconX } from '../../ui/icons';
import CapabilityDropdown from '../components/CapabilityDropdown';
import CapabilityDropdown, { CapabilityDropdownOption } from '../components/CapabilityDropdown';
const CapabilitySetsSection = ({
type CapabilityValue = string | number;
type CapabilitySetId = string | number;
interface CapabilitySet {
id: CapabilitySetId;
slug?: string;
label?: string;
capabilities?: CapabilityValue[] | null;
is_system?: boolean;
cap_version?: number | null;
}
interface CapabilitySetMutationPayload {
slug?: string;
label?: string;
capabilities: CapabilityValue[];
}
type RefreshHandler = (() => void | Promise<void>) | undefined;
type CreateCapabilitySetHandler = (payload: CapabilitySetMutationPayload) => Promise<boolean | CapabilitySet | void | null | undefined> | boolean | CapabilitySet | void | null | undefined;
type UpdateCapabilitySetHandler = (
id: CapabilitySetId,
payload: CapabilitySetMutationPayload,
) => Promise<boolean | CapabilitySet | void | null | undefined> | boolean | CapabilitySet | void | null | undefined;
type DeleteCapabilitySetHandler = (id: CapabilitySetId) => Promise<boolean | void | null | undefined> | boolean | void | null | undefined;
interface CapabilitySetsSectionProps {
capabilitySets?: CapabilitySet[];
capabilitySetsLoading?: boolean;
creatingCapabilitySet?: boolean;
savingCapabilitySetId?: CapabilitySetId | null;
deletingCapabilitySetId?: CapabilitySetId | null;
supportsCapabilitySetLabels?: boolean;
capabilities?: CapabilityValue[];
capabilitiesLoading?: boolean;
onRefreshCapabilitySets?: RefreshHandler;
onRefreshCapabilities?: RefreshHandler;
onRefresh?: RefreshHandler;
onCreateCapabilitySet?: CreateCapabilitySetHandler;
onUpdateCapabilitySet?: UpdateCapabilitySetHandler;
onDeleteCapabilitySet?: DeleteCapabilitySetHandler;
}
type CapabilityOptionInput = CapabilityDropdownOption | CapabilityValue | null | undefined;
const resolveCapabilityValue = (option: CapabilityOptionInput): CapabilityValue | null => {
if (option == null) {
return null;
}
if (typeof option === 'string' || typeof option === 'number') {
return option;
}
if (option.value != null) {
return option.value as CapabilityValue;
}
if (option.id != null) {
return option.id as CapabilityValue;
}
return null;
};
const CapabilitySetsSection: React.FC<CapabilitySetsSectionProps> = ({
capabilitySets = [],
capabilitySetsLoading = false,
creatingCapabilitySet = false,
@@ -25,58 +88,62 @@ const CapabilitySetsSection = ({
}) => {
const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState('');
const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState('');
const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]);
const [capabilitySetFormError, setCapabilitySetFormError] = useState(null);
const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState<CapabilityValue[]>([]);
const [capabilitySetFormError, setCapabilitySetFormError] = useState<string | null>(null);
const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null);
const [editingCapabilitySetId, setEditingCapabilitySetId] = useState<CapabilitySetId | null>(null);
const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState('');
const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState('');
const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]);
const [capabilitySetEditError, setCapabilitySetEditError] = useState(null);
const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState<CapabilityValue[]>([]);
const [capabilitySetEditError, setCapabilitySetEditError] = useState<string | null>(null);
const capabilitySelectionOptions = useMemo(() => (
Array.isArray(capabilities)
? capabilities.map((capability) => {
if (typeof capability?.split !== 'function') {
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}`,
};
})
: []
const capabilitySelectionOptions = useMemo<CapabilityDropdownOption[]>(() => (
(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();
const map = new Map<CapabilityValue, string>();
capabilitySelectionOptions.forEach(({ value, label }) => {
if (value == null) {
return;
}
map.set(value, label || String(value));
});
return map;
}, [capabilitySelectionOptions]);
const capabilityOrder = useMemo(() => {
const order = new Map();
const order = new Map<CapabilityValue, number>();
capabilitySelectionOptions.forEach((option, index) => {
if (option.value == null) {
return;
}
order.set(option.value, index);
});
return order;
}, [capabilitySelectionOptions]);
const sortCapabilityValues = useCallback((values) => {
const sortCapabilityValues = useCallback((values: CapabilityValue[] | null | undefined) => {
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;
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));
}
@@ -84,7 +151,7 @@ const CapabilitySetsSection = ({
});
}, [capabilityOrder]);
const formatCapabilityLabel = useCallback((value) => (
const formatCapabilityLabel = useCallback((value: CapabilityValue) => (
capabilityLabelMap.get(value) || String(value)
), [capabilityLabelMap]);
@@ -111,8 +178,8 @@ const CapabilitySetsSection = ({
onRefreshCapabilities?.();
}, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]);
const handleAddCapabilityToNewSet = useCallback((option) => {
const value = option?.value ?? option?.id ?? option;
const handleAddCapabilityToNewSet = useCallback((option: CapabilityOptionInput) => {
const value = resolveCapabilityValue(option);
if (!value) {
return;
}
@@ -125,13 +192,13 @@ const CapabilitySetsSection = ({
});
}, [sortCapabilityValues]);
const handleRemoveCapabilityFromNewSet = useCallback((value) => {
const handleRemoveCapabilityFromNewSet = useCallback((value: CapabilityValue) => {
setCapabilitySetFormError(null);
setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value));
}, []);
const handleCreateCapabilitySetSubmit = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setCapabilitySetFormError(null);
@@ -145,7 +212,7 @@ const CapabilitySetsSection = ({
return;
}
const payload = {
const payload: CapabilitySetMutationPayload = {
slug: newCapabilitySetSlug,
label: newCapabilitySetLabel,
capabilities: sortCapabilityValues(newCapabilitySetCapabilities),
@@ -172,7 +239,7 @@ const CapabilitySetsSection = ({
],
);
const handleStartEditCapabilitySet = useCallback((capabilitySet) => {
const handleStartEditCapabilitySet = useCallback((capabilitySet: CapabilitySet | null) => {
if (!capabilitySet) {
return;
}
@@ -193,8 +260,8 @@ const CapabilitySetsSection = ({
setCapabilitySetEditError(null);
}, []);
const handleAddCapabilityToEditSet = useCallback((option) => {
const value = option?.value ?? option?.id ?? option;
const handleAddCapabilityToEditSet = useCallback((option: CapabilityOptionInput) => {
const value = resolveCapabilityValue(option);
if (!value) {
return;
}
@@ -207,13 +274,13 @@ const CapabilitySetsSection = ({
});
}, [sortCapabilityValues]);
const handleRemoveCapabilityFromEditSet = useCallback((value) => {
const handleRemoveCapabilityFromEditSet = useCallback((value: CapabilityValue) => {
setCapabilitySetEditError(null);
setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value));
}, []);
const handleUpdateCapabilitySetSubmit = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editingCapabilitySetId) {
return;
@@ -223,7 +290,7 @@ const CapabilitySetsSection = ({
return;
}
const payload = {
const payload: CapabilitySetMutationPayload = {
slug: editCapabilitySetSlug,
label: editCapabilitySetLabel,
capabilities: sortCapabilityValues(editCapabilitySetCapabilities),
@@ -249,7 +316,7 @@ const CapabilitySetsSection = ({
);
const handleDeleteCapabilitySet = useCallback(
async (capabilitySet) => {
async (capabilitySet: CapabilitySet | null) => {
if (!capabilitySet?.id) {
return;
}
@@ -573,7 +640,7 @@ const CapabilitySetsSection = ({
);
};
export const CAPABILITY_SETS_SECTION = {
export const CAPABILITY_SETS_SECTION: SettingsSectionConfig = {
id: 'capabilitySets',
label: 'Capability sets',
component: CapabilitySetsSection,
@@ -1,8 +1,6 @@
import React, {
useCallback,
useMemo,
useState,
} from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import type { PasskeyRecord, RegisterPasskeyResult } from '../usePasskeys';
const formatDateTime = (value) => {
if (!value) {
@@ -15,6 +13,17 @@ const formatDateTime = (value) => {
return timestamp.toLocaleString();
};
interface PasskeysSectionProps {
passkeys?: PasskeyRecord[];
passkeysSupported?: boolean | null;
passkeysLoading?: boolean;
registeringPasskey?: boolean;
revokingPasskeyId?: string | number | null;
onRefreshPasskeys?: () => void | Promise<void>;
onRegisterPasskey?: (args: { nickname?: string }) => Promise<RegisterPasskeyResult | undefined>;
onRevokePasskey?: (id: string | number, reason?: string) => Promise<void>;
}
const PasskeysSection = ({
passkeys = [],
passkeysSupported = null,
@@ -24,7 +33,7 @@ const PasskeysSection = ({
onRefreshPasskeys,
onRegisterPasskey,
onRevokePasskey,
}) => {
}: PasskeysSectionProps) => {
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
const hasPasskeys = useMemo(() => Array.isArray(passkeys) && passkeys.length > 0, [passkeys]);
@@ -34,7 +43,7 @@ const PasskeysSection = ({
}, [onRefreshPasskeys]);
const handlePasskeyRegister = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const nickname = newPasskeyNickname.trim();
const result = await onRegisterPasskey?.({ nickname });
@@ -1,19 +1,66 @@
import { useCallback, useState } from 'react';
const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
const [tokens, setTokens] = useState([]);
interface ApiTokenRecord {
id?: string | number;
label?: string;
expires_at?: string;
created_at?: string;
last_used_at?: string;
capability_set_id?: string | number;
[key: string]: unknown;
}
interface ApiTokensResponse {
token_info?: ApiTokenRecord;
token?: string;
}
interface CreateTokenArgs {
label?: string;
expires_at?: string;
capability_set_id?: string | number;
}
interface UseApiTokensArgs {
api: {
get: <T = unknown>(url: string) => Promise<{ data: T }>;
post: <T = unknown>(url: string, payload?: unknown) => Promise<{ data: T }>;
delete: (url: string) => Promise<unknown>;
};
notifyApiError?: (error: unknown, message: string) => void;
setStatusMessage?: (message: string, variant?: string) => void;
token?: string | null;
}
interface UseApiTokensResult {
tokens: ApiTokenRecord[];
loading: boolean;
creating: boolean;
deletingId: string | number | null;
regeneratingId: string | number | null;
createdSecret: string | null;
refresh: () => Promise<void>;
create: (args?: CreateTokenArgs) => Promise<ApiTokensResponse | false>;
revoke: (tokenId?: string | number | null) => Promise<boolean>;
regenerate: (tokenId?: string | number | null) => Promise<boolean>;
dismissSecret: () => void;
}
const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTokensArgs): UseApiTokensResult => {
const [tokens, setTokens] = useState<ApiTokenRecord[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const [regeneratingId, setRegeneratingId] = useState(null);
const [createdSecret, setCreatedSecret] = useState(null);
const [deletingId, setDeletingId] = useState<string | number | null>(null);
const [regeneratingId, setRegeneratingId] = useState<string | number | null>(null);
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!token) {
return;
}
setLoading(true);
try {
const { data } = await api.get('/profile/api-tokens');
const { data } = await api.get<ApiTokenRecord[]>('/profile/api-tokens');
setTokens(Array.isArray(data) ? data : []);
} catch (error) {
notifyApiError?.(error, 'Failed to load API tokens.');
@@ -23,29 +70,25 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
}, [api, notifyApiError, token]);
const create = useCallback(
async ({ label, expires_at, capability_set_id } = {}) => {
if (creating) {
return false;
}
if (!capability_set_id) {
async ({ label, expires_at, capability_set_id }: CreateTokenArgs = {}) => {
if (creating || !capability_set_id) {
return false;
}
setCreating(true);
try {
const payload = {};
const payload: Record<string, unknown> = { capability_set_id };
if (label) {
payload.label = label;
}
if (expires_at) {
payload.expires_at = expires_at;
}
payload.capability_set_id = capability_set_id;
const { data } = await api.post('/profile/api-tokens', payload);
const { data } = await api.post<ApiTokensResponse>('/profile/api-tokens', payload);
if (data?.token_info) {
setTokens((previous) => {
const filtered = previous.filter((entry) => entry.id !== data.token_info.id);
return [data.token_info, ...filtered];
const filtered = previous.filter((entry) => entry.id !== data.token_info?.id);
return data.token_info ? [data.token_info, ...filtered] : filtered;
});
} else {
await refresh();
@@ -68,7 +111,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
);
const revoke = useCallback(
async (tokenId) => {
async (tokenId?: string | number | null) => {
if (!tokenId) {
return false;
}
@@ -89,24 +132,24 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
);
const regenerate = useCallback(
async (tokenId) => {
async (tokenId?: string | number | null) => {
if (!tokenId) {
return false;
}
setRegeneratingId(tokenId);
try {
const { data } = await api.post(`/profile/api-tokens/${tokenId}/regenerate`);
const { data } = await api.post<ApiTokensResponse>(`/profile/api-tokens/${tokenId}/regenerate`);
if (data?.token_info) {
setTokens((previous) => {
let found = false;
const next = previous.map((entry) => {
if (entry.id === data.token_info.id) {
if (entry.id === data.token_info?.id) {
found = true;
return data.token_info;
}
return entry;
});
if (!found) {
if (!found && data.token_info) {
return [data.token_info, ...previous];
}
return next;
@@ -1,7 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
const useCapabilities = ({ api, notifyApiError, token }) => {
const [capabilities, setCapabilities] = useState([]);
interface CapabilitiesApi {
get: (path: string) => Promise<{ data: unknown }>;
}
interface UseCapabilitiesOptions {
api: CapabilitiesApi;
notifyApiError?: (error: unknown, fallbackMessage: string) => void;
token?: string | null;
}
const useCapabilities = ({ api, notifyApiError, token }: UseCapabilitiesOptions) => {
const [capabilities, setCapabilities] = useState<string[]>([]);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const refreshCapabilities = useCallback(async () => {
@@ -13,7 +23,7 @@ const useCapabilities = ({ api, notifyApiError, token }) => {
try {
const { data } = await api.get('/capabilities');
if (Array.isArray(data)) {
setCapabilities(data);
setCapabilities(data as string[]);
} else {
setCapabilities([]);
}
-140
View File
@@ -1,140 +0,0 @@
import { useState, useCallback } from 'react';
import {
isWebAuthnAvailable,
preparePublicKeyCreationOptions,
serializeRegistrationCredential,
} from '../utils/webauthn';
const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }) => {
const [passkeys, setPasskeys] = useState([]);
const [passkeysSupported, setPasskeysSupported] = useState(null);
const [passkeysLoading, setPasskeysLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [revokingPasskeyId, setRevokingPasskeyId] = useState(null);
const refreshPasskeys = useCallback(async () => {
if (!token) {
return;
}
setPasskeysLoading(true);
try {
const { data } = await api.get('/profile/passkeys');
setPasskeys(Array.isArray(data) ? data : []);
setPasskeysSupported(true);
} catch (error) {
const status = error?.response?.status;
if (status === 400 || status === 404) {
setPasskeysSupported(false);
setPasskeys([]);
} else {
notifyApiError(error, 'Failed to load passkeys.');
}
} finally {
setPasskeysLoading(false);
}
}, [api, notifyApiError, token]);
const registerPasskey = useCallback(
async ({ nickname } = {}) => {
if (!isWebAuthnAvailable()) {
setPasskeysSupported(false);
setStatusMessage('Passkeys are not supported in this browser.', 'error');
return { ok: false, reason: 'unsupported' };
}
if (registeringPasskey) {
return { ok: false, reason: 'busy' };
}
setRegisteringPasskey(true);
try {
const { data } = await api.post('/auth/passkeys/register/start', {});
const challengeId = data?.challengeId || data?.challenge_id;
const publicKeyOptions =
data?.publicKey
|| data?.public_key
|| data?.challenge?.publicKey
|| data?.publicKeyCredentialCreationOptions;
if (!challengeId || !publicKeyOptions) {
throw new Error('Invalid passkey challenge response.');
}
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
const credential = await navigator.credentials.create({ publicKey });
if (!credential) {
return { ok: false, reason: 'cancelled' };
}
const serialized = serializeRegistrationCredential(credential);
const payload = {
challengeId,
credential: serialized,
};
const trimmedNickname = nickname?.trim();
if (trimmedNickname) {
payload.nickname = trimmedNickname;
}
await api.post('/auth/passkeys/register/finish', payload);
await refreshPasskeys();
setPasskeysSupported(true);
setStatusMessage('Passkey registered.', 'success');
return { ok: true };
} catch (error) {
if (error?.name === 'NotAllowedError') {
setStatusMessage('Passkey registration cancelled.', 'info');
return { ok: false, reason: 'cancelled' };
}
const status = error?.response?.status;
if (status === 400 || status === 404) {
setPasskeysSupported(false);
}
const message = error?.response?.data?.error || 'Failed to register passkey.';
notifyApiError(error, message);
return { ok: false, reason: 'error', message };
} finally {
setRegisteringPasskey(false);
}
},
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
);
const revokePasskey = useCallback(
async (passkeyId, reason) => {
if (!passkeyId) {
return { ok: false, reason: 'missing-id' };
}
setRevokingPasskeyId(passkeyId);
try {
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
await refreshPasskeys();
setStatusMessage('Passkey revoked.', 'success');
return { ok: true };
} catch (error) {
const message = error?.response?.data?.error || 'Failed to revoke passkey.';
notifyApiError(error, message);
return { ok: false, reason: 'error', message };
} finally {
setRevokingPasskeyId(null);
}
},
[api, notifyApiError, refreshPasskeys, setStatusMessage],
);
return {
passkeys,
passkeysSupported,
passkeysLoading,
registeringPasskey,
revokingPasskeyId,
refreshPasskeys,
registerPasskey,
revokePasskey,
};
};
export default usePasskeys;
+232
View File
@@ -0,0 +1,232 @@
import { useState, useCallback } from 'react';
/* global PublicKeyCredentialCreationOptions, CredentialCreationOptions */
import {
isWebAuthnAvailable,
preparePublicKeyCreationOptions,
serializeRegistrationCredential,
} from '../utils/webauthn';
type ApiClient = {
get: (path: string) => Promise<{ data: unknown }>;
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
delete: (path: string) => Promise<{ data: unknown }>;
};
type StatusMessageFn = (message: string, variant?: string) => void;
type NotifyApiErrorFn = (error: unknown, message: string) => void;
type ApiError = {
response?: {
status?: number;
data?: {
error?: string;
};
};
name?: string;
};
export interface PasskeyRecord {
id?: string | number;
nickname?: string;
created_at?: string;
createdAt?: string;
last_used_at?: string;
lastUsedAt?: string;
revoked_at?: string;
revokedAt?: string;
revoked_reason?: string;
revokedReason?: string;
transports?: string[];
[key: string]: unknown;
}
interface PasskeyChallengeResponse {
challengeId?: string;
challenge_id?: string;
publicKey?: PublicKeyCredentialCreationOptions;
public_key?: PublicKeyCredentialCreationOptions;
challenge?: {
publicKey?: PublicKeyCredentialCreationOptions;
};
publicKeyCredentialCreationOptions?: PublicKeyCredentialCreationOptions;
}
interface PasskeyRegisterPayload {
challengeId: string;
credential: unknown;
nickname?: string;
}
type RegisterPasskeyFailureReason = 'unsupported' | 'busy' | 'cancelled' | 'error';
type RegisterPasskeyResult =
| { ok: true }
| { ok: false; reason: RegisterPasskeyFailureReason; message?: string };
type RevokePasskeyFailureReason = 'missing-id' | 'error';
type RevokePasskeyResult =
| { ok: true }
| { ok: false; reason: RevokePasskeyFailureReason; message?: string };
interface UsePasskeysArgs {
api: ApiClient;
notifyApiError: NotifyApiErrorFn;
setStatusMessage: StatusMessageFn;
token?: string | null;
}
interface UsePasskeysResult {
passkeys: PasskeyRecord[];
passkeysSupported: boolean | null;
passkeysLoading: boolean;
registeringPasskey: boolean;
revokingPasskeyId: string | number | null;
refreshPasskeys: () => Promise<void>;
registerPasskey: (options?: { nickname?: string }) => Promise<RegisterPasskeyResult>;
revokePasskey: (
passkeyId: string | number | null | undefined,
reason?: string,
) => Promise<RevokePasskeyResult>;
}
const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasskeysArgs): UsePasskeysResult => {
const [passkeys, setPasskeys] = useState<PasskeyRecord[]>([]);
const [passkeysSupported, setPasskeysSupported] = useState<boolean | null>(null);
const [passkeysLoading, setPasskeysLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [revokingPasskeyId, setRevokingPasskeyId] = useState<string | number | null>(null);
const refreshPasskeys = useCallback(async (): Promise<void> => {
if (!token) {
return;
}
setPasskeysLoading(true);
try {
const { data } = await api.get('/profile/passkeys');
const passkeyData = Array.isArray(data) ? (data as PasskeyRecord[]) : [];
setPasskeys(passkeyData);
setPasskeysSupported(true);
} catch (error) {
const status = (error as ApiError)?.response?.status;
if (status === 400 || status === 404) {
setPasskeysSupported(false);
setPasskeys([]);
} else {
notifyApiError(error, 'Failed to load passkeys.');
}
} finally {
setPasskeysLoading(false);
}
}, [api, notifyApiError, token]);
const registerPasskey = useCallback(
async ({ nickname }: { nickname?: string } = {}): Promise<RegisterPasskeyResult> => {
if (!isWebAuthnAvailable()) {
setPasskeysSupported(false);
setStatusMessage('Passkeys are not supported in this browser.', 'error');
return { ok: false, reason: 'unsupported' };
}
if (registeringPasskey) {
return { ok: false, reason: 'busy' };
}
setRegisteringPasskey(true);
try {
const { data } = await api.post('/auth/passkeys/register/start', {});
const challengeData = data as PasskeyChallengeResponse | undefined;
const challengeId = challengeData?.challengeId || challengeData?.challenge_id;
const publicKeyOptions =
challengeData?.publicKey
|| challengeData?.public_key
|| challengeData?.challenge?.publicKey
|| challengeData?.publicKeyCredentialCreationOptions;
if (!challengeId || !publicKeyOptions) {
throw new Error('Invalid passkey challenge response.');
}
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
const credential = (await navigator.credentials.create({
publicKey,
} as CredentialCreationOptions)) as PublicKeyCredential | null;
if (!credential) {
return { ok: false, reason: 'cancelled' };
}
const serialized = serializeRegistrationCredential(credential);
const payload: PasskeyRegisterPayload = {
challengeId,
credential: serialized,
};
const trimmedNickname = nickname?.trim?.();
if (trimmedNickname) {
payload.nickname = trimmedNickname;
}
await api.post('/auth/passkeys/register/finish', payload);
await refreshPasskeys();
setPasskeysSupported(true);
setStatusMessage('Passkey registered.', 'success');
return { ok: true };
} catch (error) {
const typedError = error as ApiError;
if (typedError?.name === 'NotAllowedError') {
setStatusMessage('Passkey registration cancelled.', 'info');
return { ok: false, reason: 'cancelled' };
}
const status = typedError?.response?.status;
if (status === 400 || status === 404) {
setPasskeysSupported(false);
}
const message = typedError?.response?.data?.error || 'Failed to register passkey.';
notifyApiError(error, message);
return { ok: false, reason: 'error', message };
} finally {
setRegisteringPasskey(false);
}
},
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
);
const revokePasskey = useCallback(
async (
passkeyId: string | number | null | undefined,
reason?: string,
): Promise<RevokePasskeyResult> => {
if (!passkeyId) {
return { ok: false, reason: 'missing-id' };
}
setRevokingPasskeyId(passkeyId);
try {
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
await refreshPasskeys();
setStatusMessage('Passkey revoked.', 'success');
return { ok: true };
} catch (error) {
const message = (error as ApiError)?.response?.data?.error || 'Failed to revoke passkey.';
notifyApiError(error, message);
return { ok: false, reason: 'error', message };
} finally {
setRevokingPasskeyId(null);
}
},
[api, notifyApiError, refreshPasskeys, setStatusMessage],
);
return {
passkeys,
passkeysSupported,
passkeysLoading,
registeringPasskey,
revokingPasskeyId,
refreshPasskeys,
registerPasskey,
revokePasskey,
};
};
export default usePasskeys;