typescript
This commit is contained in:
@@ -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;
|
||||
+79
-29
@@ -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
|
||||
+107
-33
@@ -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'}
|
||||
+111
-44
@@ -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,
|
||||
+16
-7
@@ -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;
|
||||
+13
-3
@@ -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([]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user