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,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 });