diff --git a/package.json b/package.json index 13060f4e..7feb2caf 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,14 @@ "lint": "eslint src tests scripts eslint.config.mjs vite.config.ts vitest.config.ts --max-warnings 0", "preview": "vite preview", "keria:smoke": "tsx scripts/keria-smoke.ts", + "contact:ui-smoke": "tsx tests/contact-oobi-smoke.ts", + "contact:challenge-smoke": "tsx tests/contact-challenge-smoke.ts", "browser:smoke": "node tests/browser-smoke.mjs", "responsive:smoke": "node tests/responsive-smoke.mjs", "unit:test": "vitest run tests/unit", - "scenario:test": "vitest run tests/scenarios/salty.test.ts tests/scenarios/randy.test.ts tests/scenarios/witnessed.test.ts tests/scenarios/challenge.test.ts tests/scenarios/controller-rotation.test.ts", + "scenario:test": "vitest run tests/scenarios/salty.test.ts tests/scenarios/randy.test.ts tests/scenarios/witnessed.test.ts tests/scenarios/challenge.test.ts tests/scenarios/controller-rotation.test.ts tests/scenarios/oobi-contacts.test.ts", "scenario:test:all": "vitest run tests/scenarios", - "test:ci": "pnpm lint && pnpm build && pnpm unit:test && pnpm responsive:smoke && pnpm keria:smoke -- --mode connect && pnpm keria:smoke && pnpm scenario:test && pnpm browser:smoke" + "test:ci": "pnpm lint && pnpm build && pnpm unit:test && pnpm responsive:smoke && pnpm keria:smoke -- --mode connect && pnpm keria:smoke && pnpm scenario:test && pnpm contact:ui-smoke && pnpm browser:smoke" }, "engines": { "node": ">=20.19.0" diff --git a/src/app/ConnectDialog.tsx b/src/app/ConnectDialog.tsx index aa391b4f..bec0363f 100644 --- a/src/app/ConnectDialog.tsx +++ b/src/app/ConnectDialog.tsx @@ -8,6 +8,7 @@ import { DialogContent, DialogTitle, IconButton, + InputAdornment, Stack, TextField, Tooltip, @@ -15,6 +16,8 @@ import { } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import RefreshIcon from '@mui/icons-material/Refresh'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import { useFetcher } from 'react-router-dom'; import { appConfig, type ConnectionOption } from '../config'; import { monoValueSx } from './consoleStyles'; @@ -54,6 +57,7 @@ export const ConnectDialog = ({ useState(appConfig.connectionOptions[0]); const [draftPasscode, setDraftPasscode] = useState(null); const [copiedPasscode, setCopiedPasscode] = useState(false); + const [passcodeVisible, setPasscodeVisible] = useState(false); const isSubmitting = connection.status === 'connecting' || connectFetcher.state !== 'idle'; const isGenerating = passcodeFetcher.state !== 'idle'; @@ -187,7 +191,7 @@ export const ConnectDialog = ({ + + { + setPasscodeVisible( + (visible) => + !visible + ); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + data-testid="toggle-passcode-visibility" + > + {passcodeVisible ? ( + + ) : ( + + )} + + + + ), }, inputLabel: { shrink: true, diff --git a/src/app/Console.tsx b/src/app/Console.tsx index a818fa52..dc6adf10 100644 --- a/src/app/Console.tsx +++ b/src/app/Console.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react'; import { Box, Button, Stack, Typography } from '@mui/material'; import type { ButtonProps, SxProps, Theme } from '@mui/material'; -import { monoValueSx } from './consoleStyles'; +import { Link as RouterLink } from 'react-router-dom'; +import { clickablePanelSx, monoValueSx } from './consoleStyles'; export interface PageHeaderProps { eyebrow?: string; @@ -61,6 +62,8 @@ export interface ConsolePanelProps { eyebrow?: string; actions?: ReactNode; sx?: SxProps; + to?: string; + testId?: string; } export const ConsolePanel = ({ @@ -69,8 +72,13 @@ export const ConsolePanel = ({ eyebrow, actions, sx, + to, + testId, }: ConsolePanelProps) => ( diff --git a/src/app/NavigationDrawer.tsx b/src/app/NavigationDrawer.tsx index 864e86d7..2bb82f38 100644 --- a/src/app/NavigationDrawer.tsx +++ b/src/app/NavigationDrawer.tsx @@ -1,6 +1,7 @@ import type { KeyboardEvent } from 'react'; import { Box, + Divider, Drawer, List, ListItemButton, @@ -11,9 +12,11 @@ import { } from '@mui/material'; import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined'; import CreditCardIcon from '@mui/icons-material/CreditCard'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import ListAltIcon from '@mui/icons-material/ListAlt'; import NotificationsIcon from '@mui/icons-material/Notifications'; import TerminalIcon from '@mui/icons-material/Terminal'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import { useLocation, useNavigate } from 'react-router-dom'; import { APP_NAV_ITEMS } from './router'; import type { AppRouteId } from './router'; @@ -26,6 +29,13 @@ export interface NavigationDrawerProps { open: boolean; /** Close the drawer after backdrop, keyboard, or item selection events. */ onClose: () => void; + /** Clear all persisted local app state for every controller bucket. */ + onClearLocalState: () => void; +} + +export interface DesktopNavigationRailProps { + /** Clear all persisted local app state for every controller bucket. */ + onClearLocalState: () => void; } const routeIcon = (routeId: AppRouteId) => { @@ -67,6 +77,57 @@ const navButtonSx = (active: boolean) => ({ }, }); +const LOCAL_STATE_CLEAR_CONFIRMATION = + 'Clear all saved local app state for every identifier? This removes operation history, app notifications, dismissed exchange records, and saved challenge words stored in this browser.'; + +const ClearLocalStateIcon = () => ( + + + + +); + +const clearLocalStateButtonSx = { + ...navButtonSx(false), + color: 'error.main', + borderColor: 'transparent', + '&:hover': { + borderColor: 'error.main', + bgcolor: 'rgba(255, 75, 90, 0.08)', + color: 'error.main', + }, + '.MuiListItemIcon-root': { + minWidth: 38, + }, +}; + +const confirmAndClearLocalState = ( + onClearLocalState: () => void, + onClose?: () => void +) => { + const confirmed = globalThis.confirm(LOCAL_STATE_CLEAR_CONFIRMATION); + if (!confirmed) { + return; + } + + onClearLocalState(); + onClose?.(); +}; + /** * Drawer generated from data-router route handles. * @@ -74,7 +135,11 @@ const navButtonSx = (active: boolean) => ({ * drawer item should mean updating the route descriptor, not hardcoding a * second navigation list here. */ -export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => { +export const NavigationDrawer = ({ + open, + onClose, + onClearLocalState, +}: NavigationDrawerProps) => { const navigate = useNavigate(); const location = useLocation(); @@ -99,7 +164,15 @@ export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => { }, }} > -
+ {APP_NAV_ITEMS.map((view) => ( { ))} -
+ + + + confirmAndClearLocalState(onClearLocalState, onClose) + } + data-testid="clear-local-state" + sx={clearLocalStateButtonSx} + > + + + + ); }; -export const DesktopNavigationRail = () => { +export const DesktopNavigationRail = ({ + onClearLocalState, +}: DesktopNavigationRailProps) => { const navigate = useNavigate(); const location = useLocation(); @@ -187,6 +277,31 @@ export const DesktopNavigationRail = () => { ); })} + + + + confirmAndClearLocalState(onClearLocalState)} + data-testid="rail-clear-local-state" + sx={{ + ...clearLocalStateButtonSx, + mx: 0, + minHeight: 48, + }} + > + + + Clear local state + + } + /> + + ); }; diff --git a/src/app/PayloadDetails.tsx b/src/app/PayloadDetails.tsx new file mode 100644 index 00000000..55ba4d29 --- /dev/null +++ b/src/app/PayloadDetails.tsx @@ -0,0 +1,120 @@ +import { Box, Stack, Tooltip, Typography } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { monoValueSx } from './consoleStyles'; +import type { PayloadDetailRecord } from '../state/payloadDetails'; + +const abbreviate = (value: string, maxLength: number): string => { + if (value.length <= maxLength) { + return value; + } + + const edgeLength = Math.max(8, Math.floor((maxLength - 3) / 2)); + return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; +}; + +const copyValue = (value: string): void => { + void globalThis.navigator?.clipboard?.writeText(value); +}; + +export interface PayloadDetailsProps { + details: readonly PayloadDetailRecord[]; + dense?: boolean; + maxLength?: number; +} + +/** + * Render meaningful operation/notification payloads as compact copy targets. + */ +export const PayloadDetails = ({ + details, + dense = false, + maxLength = dense ? 52 : 84, +}: PayloadDetailsProps) => { + if (details.length === 0) { + return null; + } + + return ( + + {details.map((detail) => ( + + { + if (!detail.copyable) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + copyValue(detail.value); + }} + onKeyDown={(event) => { + if ( + !detail.copyable || + (event.key !== 'Enter' && event.key !== ' ') + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + copyValue(detail.value); + }} + data-testid="payload-detail" + data-payload-kind={detail.kind} + sx={{ + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', + alignItems: 'center', + gap: 0.75, + border: 1, + borderColor: 'divider', + borderRadius: 1, + px: dense ? 0.75 : 1, + py: dense ? 0.5 : 0.75, + bgcolor: 'rgba(39, 215, 255, 0.06)', + cursor: detail.copyable ? 'copy' : 'default', + minWidth: 0, + }} + > + + {detail.label} + + + {abbreviate(detail.value, maxLength)} + + {detail.copyable && ( + + )} + + + ))} + + ); +}; diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 85e09102..299af509 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Box } from '@mui/material'; import { Outlet, useFetchers, useNavigation } from 'react-router-dom'; import type { AppRuntime } from './runtime'; -import { useAppSession } from './runtimeHooks'; +import { useAppRuntime, useAppSession } from './runtimeHooks'; import { AppRuntimeProvider } from './runtimeContext'; import { ConnectDialog } from './ConnectDialog'; import { derivePendingState } from './pendingState'; @@ -12,7 +12,9 @@ import { TopBar } from './TopBar'; import { useAppSelector } from '../state/hooks'; import { selectActiveOperations, + selectActionableChallengeRequestNotifications, selectAppNotifications, + selectIdentifiers, selectUnreadAppNotifications, } from '../state/selectors'; @@ -34,10 +36,15 @@ const RootLayoutContent = () => { const [drawerOpen, setDrawerOpen] = useState(false); const navigation = useNavigation(); const fetchers = useFetchers(); + const runtime = useAppRuntime(); const { connection } = useAppSession(); const activeOperations = useAppSelector(selectActiveOperations); const appNotifications = useAppSelector(selectAppNotifications); const unreadAppNotifications = useAppSelector(selectUnreadAppNotifications); + const challengeRequests = useAppSelector( + selectActionableChallengeRequestNotifications + ); + const identifiers = useAppSelector(selectIdentifiers); const connectDialogOpen = connectOpen && connection.status !== 'connected'; const pending = derivePendingState({ navigation, @@ -57,15 +64,26 @@ const RootLayoutContent = () => { isConnected={connection.status === 'connected'} activeOperations={activeOperations} recentNotifications={appNotifications} - unreadNotificationCount={unreadAppNotifications.length} + challengeRequests={challengeRequests} + identifiers={identifiers} + unreadNotificationCount={ + unreadAppNotifications.length + challengeRequests.length + } onMenuClick={() => setDrawerOpen(true)} onConnectClick={() => setConnectOpen(true)} /> setDrawerOpen(false)} + onClearLocalState={() => { + runtime.clearAllLocalState(); + }} + /> + { + runtime.clearAllLocalState(); + }} /> - void; @@ -56,6 +69,8 @@ export const TopBar = ({ isConnected, activeOperations, recentNotifications, + challengeRequests, + identifiers, unreadNotificationCount, onMenuClick, onConnectClick, @@ -64,6 +79,7 @@ export const TopBar = ({ useState(null); const [notificationsAnchor, setNotificationsAnchor] = useState(null); + const dismissFetcher = useFetcher(); const dispatch = useAppDispatch(); const operationsOpen = operationsAnchor !== null; const notificationsOpen = notificationsAnchor !== null; @@ -71,6 +87,10 @@ export const TopBar = ({ () => recentNotifications.slice(0, 5), [recentNotifications] ); + const visibleChallengeRequests = useMemo( + () => challengeRequests.slice(0, 3), + [challengeRequests] + ); useEffect(() => { if (!notificationsOpen || unreadNotificationCount === 0) { @@ -94,6 +114,19 @@ export const TopBar = ({ setNotificationsAnchor(event.currentTarget); }; + const dismissChallengeRequest = (request: ChallengeRequestNotification) => { + const formData = new FormData(); + formData.set('intent', 'dismissExchangeNotification'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('notificationId', request.notificationId); + formData.set('exnSaid', request.exnSaid); + formData.set('route', '/challenge/request'); + dismissFetcher.submit(formData, { + method: 'post', + action: '/notifications', + }); + }; + return ( - {recentNotifications.length === 0 ? ( + {recentNotifications.length === 0 && + visibleChallengeRequests.length === 0 ? ( ) : ( - visibleNotifications.map((notification) => ( - setNotificationsAnchor(null)} - sx={{ - bgcolor: - notification.status === 'unread' - ? 'action.selected' - : 'action.hover', - color: 'text.primary', - border: 1, - borderColor: - notification.status === 'unread' - ? 'primary.main' - : 'divider', - borderRadius: 1, - mb: 0.75, - }} - > - - {formatTimestamp( - notification.createdAt - ) !== null && ( + <> + {visibleChallengeRequests.map((request) => ( + + + + + Challenge request + + - Created{' '} - {formatTimestamp( - notification.createdAt + From {request.senderAlias} ( + {abbreviateMiddle( + request.senderAid, + 28 )} + ) - )} - + {formatTimestamp( + request.createdAt + )} + + )} + + - {notification.message} - - + + + + + dismissChallengeRequest( + request + ) + } + > + + + + + + + + +
+ ))} + {visibleChallengeRequests.length > 0 && + visibleNotifications.length > 0 && ( + + )} + {visibleNotifications.map((notification) => ( + - - )) + onClick={() => setNotificationsAnchor(null)} + sx={{ + bgcolor: + notification.status === 'unread' + ? 'action.selected' + : 'action.hover', + color: 'text.primary', + border: 1, + borderColor: + notification.status === 'unread' + ? 'primary.main' + : 'divider', + borderRadius: 1, + mb: 0.75, + }} + > + + {formatTimestamp( + notification.createdAt + ) !== null && ( + + Created{' '} + {formatTimestamp( + notification.createdAt + )} + + )} + + {notification.message} + + + + } + /> + + ))} + )} ; /** Refresh the normalized Signify state through the connected client. */ - refreshState(options?: { signal?: AbortSignal }): Promise; + refreshState(options?: { + signal?: AbortSignal; + }): Promise; /** Load and normalize identifiers through the connected client. */ - listIdentifiers(options?: { signal?: AbortSignal }): Promise; + listIdentifiers(options?: { + signal?: AbortSignal; + }): Promise; + /** Load live contact, challenge, and protocol notification facts. */ + syncSessionInventory(options?: { signal?: AbortSignal }): Promise; /** Create an identifier and wait for its KERIA operation to complete. */ createIdentifier( draft: IdentifierCreateDraft, @@ -134,6 +221,51 @@ export interface RouteDataRuntime { aid: string, options?: { requestId?: string } ): BackgroundWorkflowStartResult; + /** Start OOBI generation in the background. */ + startGenerateOobi( + input: { identifier: string; role: OobiRole }, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start contact OOBI resolution in the background. */ + startResolveContact( + input: ResolveContactInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start contact deletion in the background. */ + startDeleteContact( + contactId: string, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start contact alias update in the background. */ + startUpdateContactAlias( + input: { contactId: string; alias: string }, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Generate challenge words and record them in session state. */ + generateContactChallenge( + input: GenerateContactChallengeInput, + options?: { signal?: AbortSignal; requestId?: string } + ): Promise; + /** Start challenge response sending in the background. */ + startRespondToChallenge( + input: RespondToContactChallengeInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start challenge request notification sending in the background. */ + startSendChallengeRequest( + input: SendChallengeRequestInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start challenger-side verification in the background. */ + startVerifyContactChallenge( + input: VerifyContactChallengeInput, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Locally tombstone and optionally delete a KERIA notification note. */ + dismissExchangeNotification( + input: DismissExchangeNotificationInput, + options?: { signal?: AbortSignal; requestId?: string } + ): Promise; } /** @@ -168,6 +300,98 @@ const parseIdentifierCreateDraft = ( } }; +const parseOobiRole = (value: string): OobiRole | null => + value === 'agent' || value === 'witness' ? value : null; + +const contactIntentFromString = ( + value: string +): Exclude => + value === 'generateOobi' || + value === 'generateChallenge' || + value === 'respondChallenge' || + value === 'verifyChallenge' || + value === 'dismissExchangeNotification' || + value === 'delete' || + value === 'updateAlias' + ? value + : 'resolve'; + +/** + * Loader for `/dashboard`. + */ +export const loadDashboard = async ( + runtime: RouteDataRuntime, + request?: Request +): Promise => { + if (runtime.getClient() === null) { + return { status: 'blocked' }; + } + + try { + await Promise.all([ + runtime.listIdentifiers({ signal: request?.signal }), + runtime.syncSessionInventory({ signal: request?.signal }), + ]); + return { status: 'ready' }; + } catch (error) { + return { + status: 'error', + message: `Unable to refresh dashboard inventory: ${toRouteError(error).message}`, + }; + } +}; + +/** + * Loader for `/contacts`. + */ +export const loadContacts = async ( + runtime: RouteDataRuntime, + request?: Request +): Promise => { + if (runtime.getClient() === null) { + return { status: 'blocked' }; + } + + try { + await Promise.all([ + runtime.listIdentifiers({ signal: request?.signal }), + runtime.syncSessionInventory({ signal: request?.signal }), + ]); + return { status: 'ready' }; + } catch (error) { + return { + status: 'error', + message: `Unable to refresh contact inventory: ${toRouteError(error).message}`, + }; + } +}; + +/** + * Loader for `/notifications`. + */ +export const loadNotifications = async ( + runtime: RouteDataRuntime, + request?: Request +): Promise => { + if (runtime.getClient() === null) { + return { status: 'blocked' }; + } + + try { + const [identifiers] = await Promise.all([ + runtime.listIdentifiers({ signal: request?.signal }), + runtime.syncSessionInventory({ signal: request?.signal }), + ]); + return { status: 'ready', identifiers }; + } catch (error) { + return { + status: 'error', + identifiers: [], + message: `Unable to refresh notifications: ${toRouteError(error).message}`, + }; + } +}; + /** * Loader for `/identifiers`. * @@ -220,7 +444,9 @@ export const loadClient = async ( const summary = (await runtime.refreshState({ signal: request?.signal })) ?? runtime.getState(); - return summary === null ? { status: 'blocked' } : { status: 'ready', summary }; + return summary === null + ? { status: 'blocked' } + : { status: 'ready', summary }; }; /** @@ -400,3 +626,441 @@ export const identifiersAction = async ( message: `Unsupported identifier action: ${intent || 'missing intent'}`, }; }; + +/** + * Route action for contact/OOBI mutations. + */ +export const contactsAction = async ( + runtime: RouteDataRuntime, + request: Request +): Promise => { + const formData = await request.formData(); + const intent = formString(formData, 'intent'); + const requestId = formString(formData, 'requestId'); + + if (runtime.getClient() === null) { + return { + intent: contactIntentFromString(intent), + ok: false, + message: 'Connect to KERIA before changing contacts.', + requestId, + }; + } + + try { + if (intent === 'resolve') { + const oobi = formString(formData, 'oobi').trim(); + const alias = formString(formData, 'alias').trim(); + if (oobi.length === 0) { + return { + intent, + ok: false, + message: 'OOBI URL is required.', + requestId, + }; + } + + const started = runtime.startResolveContact( + { + oobi, + alias: alias.length > 0 ? alias : null, + }, + { requestId: requestId || undefined } + ); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: 'Resolving contact OOBI', + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'generateOobi') { + const identifier = formString(formData, 'identifier').trim(); + const role = parseOobiRole(formString(formData, 'role')); + if (identifier.length === 0 || role === null) { + return { + intent, + ok: false, + message: 'Identifier and OOBI role are required.', + requestId, + }; + } + + const started = runtime.startGenerateOobi( + { identifier, role }, + { requestId: requestId || undefined } + ); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: `Generating ${role} OOBI for ${identifier}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'generateChallenge') { + const contactId = formString(formData, 'contactId').trim(); + const contactAlias = formString(formData, 'contactAlias').trim(); + const localIdentifier = formString( + formData, + 'localIdentifier' + ).trim(); + const localAid = formString(formData, 'localAid').trim(); + if (contactId.length === 0 || localIdentifier.length === 0) { + return { + intent, + ok: false, + message: 'Contact id and local identifier are required.', + requestId, + }; + } + + const generated = await runtime.generateContactChallenge( + { + counterpartyAid: contactId, + counterpartyAlias: + contactAlias.length > 0 ? contactAlias : null, + localIdentifier, + localAid: localAid.length > 0 ? localAid : null, + }, + { signal: request.signal } + ); + runtime.startSendChallengeRequest( + { + challengeId: generated.challengeId, + counterpartyAid: generated.counterpartyAid, + counterpartyAlias: generated.counterpartyAlias, + localIdentifier: generated.localIdentifier, + localAid: generated.localAid, + wordsHash: generated.wordsHash, + strength: generated.strength, + }, + { + requestId: requestId + ? `${requestId}:challenge-request` + : undefined, + } + ); + const started = runtime.startVerifyContactChallenge( + { + challengeId: generated.challengeId, + counterpartyAid: generated.counterpartyAid, + counterpartyAlias: generated.counterpartyAlias, + localIdentifier: generated.localIdentifier, + localAid: generated.localAid, + words: generated.words, + wordsHash: generated.wordsHash, + generatedAt: generated.generatedAt, + }, + { requestId: requestId || undefined } + ); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: + 'Generated challenge, sent request, and started verification', + requestId: started.requestId, + operationRoute: started.operationRoute, + challenge: generated, + }; + } + + if (intent === 'respondChallenge') { + const notificationId = formString( + formData, + 'notificationId' + ).trim(); + const challengeId = formString(formData, 'challengeId').trim(); + const wordsHash = formString(formData, 'wordsHash').trim(); + const contactId = formString(formData, 'contactId').trim(); + const contactAlias = formString(formData, 'contactAlias').trim(); + const localIdentifier = formString( + formData, + 'localIdentifier' + ).trim(); + const localAid = formString(formData, 'localAid').trim(); + const words = parseChallengeWords(formString(formData, 'words')); + const wordError = validateChallengeWords(words); + if (contactId.length === 0 || localIdentifier.length === 0) { + return { + intent, + ok: false, + message: 'Contact id and local identifier are required.', + requestId, + }; + } + + if (wordError !== null) { + return { + intent, + ok: false, + message: wordError, + requestId, + }; + } + + const started = runtime.startRespondToChallenge( + { + challengeId: + challengeId.length > 0 + ? challengeId + : requestId || undefined, + notificationId: + notificationId.length > 0 ? notificationId : undefined, + wordsHash: wordsHash.length > 0 ? wordsHash : null, + counterpartyAid: contactId, + counterpartyAlias: + contactAlias.length > 0 ? contactAlias : null, + localIdentifier, + localAid: localAid.length > 0 ? localAid : null, + words, + }, + { requestId: requestId || undefined } + ); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: `Sending challenge response to ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'verifyChallenge') { + const challengeId = formString(formData, 'challengeId').trim(); + const contactId = formString(formData, 'contactId').trim(); + const contactAlias = formString(formData, 'contactAlias').trim(); + const localIdentifier = formString( + formData, + 'localIdentifier' + ).trim(); + const localAid = formString(formData, 'localAid').trim(); + const words = parseChallengeWords(formString(formData, 'words')); + const wordsHash = formString(formData, 'wordsHash').trim(); + const generatedAt = formString(formData, 'generatedAt').trim(); + const wordError = validateChallengeWords(words); + if ( + challengeId.length === 0 || + contactId.length === 0 || + localIdentifier.length === 0 + ) { + return { + intent, + ok: false, + message: + 'Challenge id, contact id, and local identifier are required.', + requestId, + }; + } + + if (wordError !== null) { + return { + intent, + ok: false, + message: wordError, + requestId, + }; + } + + const started = runtime.startVerifyContactChallenge( + { + challengeId, + counterpartyAid: contactId, + counterpartyAlias: + contactAlias.length > 0 ? contactAlias : null, + localIdentifier, + localAid: localAid.length > 0 ? localAid : null, + words, + wordsHash: wordsHash.length > 0 ? wordsHash : null, + generatedAt: generatedAt.length > 0 ? generatedAt : null, + }, + { requestId: requestId || undefined } + ); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: `Waiting for challenge response from ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'dismissExchangeNotification') { + const notificationId = formString( + formData, + 'notificationId' + ).trim(); + const exnSaid = formString(formData, 'exnSaid').trim(); + const route = formString(formData, 'route').trim(); + if ( + notificationId.length === 0 || + exnSaid.length === 0 || + route.length === 0 + ) { + return { + intent, + ok: false, + message: + 'Notification id, EXN SAID, and route are required.', + requestId, + }; + } + + await runtime.dismissExchangeNotification( + { notificationId, exnSaid, route }, + { requestId: requestId || undefined, signal: request.signal } + ); + + return { + intent, + ok: true, + message: 'Exchange notification dismissed.', + requestId: requestId || '', + operationRoute: '/notifications', + }; + } + + if (intent === 'delete') { + const contactId = formString(formData, 'contactId').trim(); + if (contactId.length === 0) { + return { + intent, + ok: false, + message: 'Contact id is required.', + requestId, + }; + } + + const started = runtime.startDeleteContact(contactId, { + requestId: requestId || undefined, + }); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: `Deleting contact ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + if (intent === 'updateAlias') { + const contactId = formString(formData, 'contactId').trim(); + const alias = formString(formData, 'alias').trim(); + if (contactId.length === 0 || alias.length === 0) { + return { + intent, + ok: false, + message: 'Contact id and alias are required.', + requestId, + }; + } + + const started = runtime.startUpdateContactAlias( + { contactId, alias }, + { requestId: requestId || undefined } + ); + if (started.status === 'conflict') { + return { + intent, + ok: false, + message: started.message, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + + return { + intent, + ok: true, + message: `Updating contact ${contactId}`, + requestId: started.requestId, + operationRoute: started.operationRoute, + }; + } + } catch (error) { + return { + intent: contactIntentFromString(intent), + ok: false, + message: toRouteError(error).message, + requestId, + }; + } + + return { + intent: 'unsupported', + ok: false, + message: `Unsupported contact action: ${intent || 'missing intent'}`, + requestId, + }; +}; + +/** + * Notification actions share the contact challenge response path. + */ +export const notificationsAction = async ( + runtime: RouteDataRuntime, + request: Request +): Promise => contactsAction(runtime, request); diff --git a/src/app/router.tsx b/src/app/router.tsx index b410437a..a6a6edf8 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -4,18 +4,27 @@ import { type RouteObject, } from 'react-router-dom'; import { ClientView } from '../features/client/ClientView'; +import { ContactDetailView } from '../features/contacts/ContactDetailView'; +import { ContactsView } from '../features/contacts/ContactsView'; import { CredentialsView } from '../features/credentials/CredentialsView'; +import { DashboardView } from '../features/dashboard/DashboardView'; import { IdentifiersView } from '../features/identifiers/IdentifiersView'; import { AppNotificationsView } from '../features/notifications/AppNotificationsView'; +import { NotificationDetailView } from '../features/notifications/NotificationDetailView'; import { OperationDetailView } from '../features/operations/OperationDetailView'; import { OperationsView } from '../features/operations/OperationsView'; import type { AppRuntime } from './runtime'; import { DEFAULT_APP_PATH, + contactsAction, identifiersAction, + loadContacts, + loadDashboard, loadClient, loadCredentials, loadIdentifiers, + loadNotifications, + notificationsAction, rootAction, } from './routeData'; import { RootLayout } from './RootLayout'; @@ -25,6 +34,8 @@ import { RouteErrorBoundary } from './RouteErrorBoundary'; * Stable IDs for feature routes that appear in the app shell. */ export type AppRouteId = + | 'dashboard' + | 'contacts' | 'identifiers' | 'credentials' | 'client' @@ -85,6 +96,28 @@ interface AppFeatureRouteDescriptor { * Ordered feature route registry used only to build native route objects. */ const APP_FEATURE_ROUTES: readonly AppFeatureRouteDescriptor[] = [ + { + id: 'dashboard', + path: 'dashboard', + handle: { + routeId: 'dashboard', + label: 'Dashboard', + gate: 'client', + nav: true, + testId: 'nav-dashboard', + }, + }, + { + id: 'contacts', + path: 'contacts', + handle: { + routeId: 'contacts', + label: 'Contacts', + gate: 'client', + nav: true, + testId: 'nav-contacts', + }, + }, { id: 'identifiers', path: 'identifiers', @@ -172,10 +205,41 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ index: true, loader: () => redirect(DEFAULT_APP_PATH), }, + { + id: 'dashboard', + path: 'dashboard', + handle: APP_FEATURE_ROUTES[0].handle, + loader: ({ request }) => loadDashboard(runtime, request), + element: , + errorElement: ( + + ), + }, + { + id: 'contacts', + path: 'contacts', + handle: APP_FEATURE_ROUTES[1].handle, + loader: ({ request }) => loadContacts(runtime, request), + action: ({ request }) => contactsAction(runtime, request), + element: , + errorElement: ( + + ), + }, + { + id: 'contactDetail', + path: 'contacts/:contactId', + loader: ({ request }) => loadContacts(runtime, request), + action: ({ request }) => contactsAction(runtime, request), + element: , + errorElement: ( + + ), + }, { id: 'identifiers', path: 'identifiers', - handle: APP_FEATURE_ROUTES[0].handle, + handle: APP_FEATURE_ROUTES[2].handle, loader: ({ request }) => loadIdentifiers(runtime, request), action: ({ request }) => identifiersAction(runtime, request), element: , @@ -186,7 +250,7 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ { id: 'credentials', path: 'credentials', - handle: APP_FEATURE_ROUTES[1].handle, + handle: APP_FEATURE_ROUTES[3].handle, loader: () => loadCredentials(runtime), element: , errorElement: ( @@ -196,15 +260,17 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ { id: 'client', path: 'client', - handle: APP_FEATURE_ROUTES[2].handle, + handle: APP_FEATURE_ROUTES[4].handle, loader: ({ request }) => loadClient(runtime, request), element: , - errorElement: , + errorElement: ( + + ), }, { id: 'operations', path: 'operations', - handle: APP_FEATURE_ROUTES[3].handle, + handle: APP_FEATURE_ROUTES[5].handle, element: , errorElement: ( @@ -221,12 +287,24 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ { id: 'appNotifications', path: 'notifications', - handle: APP_FEATURE_ROUTES[4].handle, + handle: APP_FEATURE_ROUTES[6].handle, + loader: ({ request }) => loadNotifications(runtime, request), + action: ({ request }) => notificationsAction(runtime, request), element: , errorElement: ( ), }, + { + id: 'notificationDetail', + path: 'notifications/:notificationId', + loader: ({ request }) => loadNotifications(runtime, request), + action: ({ request }) => notificationsAction(runtime, request), + element: , + errorElement: ( + + ), + }, { path: '*', loader: () => redirect(DEFAULT_APP_PATH), diff --git a/src/app/runtime.ts b/src/app/runtime.ts index f2f122f3..6c05114d 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -3,10 +3,13 @@ import type { SignifyClient } from 'signify-ts'; import { appConfig, type AppConfig } from '../config'; import { toErrorText } from '../effects/promise'; import { AppEffectionScopes, type RuntimeScopeKind } from '../effects/scope'; +import { aliasForOobiResolution } from '../features/contacts/contactHelpers'; import type { IdentifierCreateDraft, IdentifierSummary, } from '../features/identifiers/identifierTypes'; +import type { ResolveContactInput } from '../services/contacts.service'; +import type { GeneratedOobiRecord } from '../state/contacts.slice'; import { toError, type ConnectedSignifyClient, @@ -15,28 +18,41 @@ import { type SignifyStateSummary, } from '../signify/client'; import { + appNotificationsRehydrated, appNotificationRecorded, type AppNotificationLink, type AppNotificationRecord, type AppNotificationSeverity, } from '../state/appNotifications.slice'; +import { storedChallengeWordsRehydrated } from '../state/challenges.slice'; +import { exchangeTombstonesRehydrated } from '../state/exchangeTombstones.slice'; import { cancelRunningOperations, type OperationKind, type OperationRouteLink, operationCanceled, operationFailed, + operationPayloadDetailsRecorded, + operationsRehydrated, operationResultLinked, operationStarted, operationSucceeded, } from '../state/operations.slice'; +import type { PayloadDetailRecord } from '../state/payloadDetails'; import { + clearAllPersistedAppStates, flushPersistedAppState, installAppStatePersistence, rehydratePersistedAppState, type AppStateStorage, } from '../state/persistence'; -import { sessionDisconnected } from '../state/session.slice'; +import { + sessionConnected, + sessionConnectionFailed, + sessionConnecting, + sessionDisconnected, + sessionStateRefreshed, +} from '../state/session.slice'; import { appStore, type AppStore } from '../state/store'; import { createIdentifierOp, @@ -44,11 +60,38 @@ import { listIdentifiersOp, rotateIdentifierOp, } from '../workflows/identifiers.op'; +import { + deleteContactOp, + generateOobiOp, + liveSessionInventoryOp, + resolveContactOobiOp, + syncSessionInventoryOp, + updateContactAliasOp, + type GenerateOobiInput, + type SessionInventorySnapshot, + type UpdateContactAliasInput, +} from '../workflows/contacts.op'; +import { + challengeResultRoute, + generateContactChallengeOp, + respondToContactChallengeOp, + sendChallengeRequestOp, + verifyContactChallengeOp, + type GeneratedContactChallengeResult, + type GenerateContactChallengeInput, + type RespondToContactChallengeInput, + type SendChallengeRequestInput, + type VerifyContactChallengeInput, +} from '../workflows/challenges.op'; import { bootOrConnectOp, getSignifyStateOp, randomPasscodeOp, } from '../workflows/signify.op'; +import { + dismissExchangeNotificationOp, + type DismissExchangeNotificationInput, +} from '../workflows/notifications.op'; /** * Complete connection-state model for the app runtime. @@ -205,6 +248,87 @@ const operationRoute = (requestId: string): string => const notificationId = (requestId: string): string => `notification-${requestId}-${Date.now()}`; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const stringValue = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; + +const stringArray = (value: unknown): string[] => + Array.isArray(value) + ? value.flatMap((item) => { + const text = stringValue(item); + return text === null ? [] : [text]; + }) + : []; + +const detailId = (label: string, index: number): string => + `${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${index}`; + +const payloadDetailsFromWorkflowResult = ( + result: unknown +): PayloadDetailRecord[] => { + if (!isRecord(result)) { + return []; + } + + const details: PayloadDetailRecord[] = []; + const generatedOobis = stringArray(result.oobis); + generatedOobis.forEach((oobi, index) => { + details.push({ + id: detailId('generated-oobi', index), + label: generatedOobis.length === 1 ? 'OOBI' : `OOBI ${index + 1}`, + value: oobi, + kind: 'oobi', + copyable: true, + }); + }); + + const sourceOobi = stringValue(result.sourceOobi); + if (sourceOobi !== null) { + details.push({ + id: detailId('source-oobi', details.length), + label: 'OOBI', + value: sourceOobi, + kind: 'oobi', + copyable: true, + }); + } + + const resolutionOobi = stringValue(result.resolutionOobi); + if (resolutionOobi !== null && resolutionOobi !== sourceOobi) { + details.push({ + id: detailId('resolution-oobi', details.length), + label: 'Resolved URL', + value: resolutionOobi, + kind: 'oobi', + copyable: true, + }); + } + + const resolvedAid = stringValue(result.resolvedAid); + if (resolvedAid !== null) { + details.push({ + id: detailId('resolved-aid', details.length), + label: 'AID', + value: resolvedAid, + kind: 'aid', + copyable: true, + }); + } + + const seen = new Set(); + return details.filter((detail) => { + const key = `${detail.label}:${detail.value}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +}; + /** * Data-router-safe Signify session and command boundary. * @@ -222,6 +346,8 @@ export class AppRuntime { private readonly activeTasks = new Map>(); + private liveSyncTask: Task | null = null; + private readonly storage: AppStateStorage | null | undefined; private currentControllerAid: string | null = null; @@ -312,6 +438,7 @@ export class AppRuntime { config: SignifyClientConfig, options: WorkflowRunOptions = {} ): Promise => { + this.store.dispatch(sessionConnecting()); this.setConnection({ status: 'connecting', client: null, @@ -339,9 +466,19 @@ export class AppRuntime { error: null, booted: connected.booted, }); + this.store.dispatch( + sessionConnected({ + booted: connected.booted, + controllerAid: connected.state.controllerPre, + agentAid: connected.state.agentPre, + connectedAt: new Date().toISOString(), + }) + ); + this.startLiveSync(); return connected; } catch (error) { const normalized = toError(error); + this.store.dispatch(sessionConnectionFailed(normalized.message)); this.setConnection({ status: 'error', client: null, @@ -362,6 +499,7 @@ export class AppRuntime { reason: 'Session disconnected.', }) ); + void this.stopLiveSync(); this.flushPersistence(); void this.scopes.haltSession(); this.store.dispatch(sessionDisconnected()); @@ -405,9 +543,50 @@ export class AppRuntime { ...connection, state, }); + this.store.dispatch( + sessionStateRefreshed({ + controllerAid: state.controllerPre, + agentAid: state.agentPre, + }) + ); return state; }; + /** + * Clear all browser-persisted app state buckets and the current in-memory + * projections that are backed by those buckets. + */ + clearAllLocalState = (): number => { + const previousControllerAid = this.currentControllerAid; + this.currentControllerAid = null; + try { + this.store.dispatch( + operationsRehydrated({ + records: [], + interruptedAt: new Date().toISOString(), + }) + ); + this.store.dispatch( + appNotificationsRehydrated({ + records: [], + }) + ); + this.store.dispatch( + exchangeTombstonesRehydrated({ + records: [], + }) + ); + this.store.dispatch( + storedChallengeWordsRehydrated({ + records: [], + }) + ); + return clearAllPersistedAppStates(this.storage); + } finally { + this.currentControllerAid = previousControllerAid; + } + }; + /** * List identifiers through the connected Signify client and normalize the * response shape for route loader consumers. @@ -435,6 +614,47 @@ export class AppRuntime { track: options.track ?? false, }); + /** + * Fetch an OOBI for one managed identifier role without recording an + * operation-history item by default. Agent OOBIs still authorize the agent + * endpoint role through the shared OOBI workflow when needed. + */ + getIdentifierOobi = async ( + input: GenerateOobiInput, + options: WorkflowRunOptions = {} + ): Promise => + this.runWorkflow(() => generateOobiOp(input), { + ...options, + label: options.label, + kind: options.kind ?? 'generateOobi', + track: options.track ?? false, + }); + + /** + * Fetch all requested OOBI roles for one managed identifier. + */ + listIdentifierOobis = async ( + identifier: string, + roles: readonly GenerateOobiInput['role'][], + options: WorkflowRunOptions = {} + ): Promise => { + const records: GeneratedOobiRecord[] = []; + for (const role of roles) { + records.push( + await this.getIdentifierOobi( + { identifier, role }, + { + ...options, + label: options.label, + track: options.track ?? false, + } + ) + ); + } + + return records; + }; + /** * Create an identifier, wait for the resulting KERIA operation, then return * a freshly loaded identifier list for router revalidation callers. @@ -465,6 +685,19 @@ export class AppRuntime { }); }; + /** + * Refresh live dashboard/contact facts without recording operation history. + */ + syncSessionInventory = async ( + options: WorkflowRunOptions = {} + ): Promise => + this.runWorkflow(() => syncSessionInventoryOp(), { + ...options, + label: options.label, + kind: options.kind ?? 'syncInventory', + track: options.track ?? false, + }); + startCreateIdentifier = ( draft: IdentifierCreateDraft, options: Pick = {} @@ -517,6 +750,231 @@ export class AppRuntime { }, }); + startGenerateOobi = ( + input: GenerateOobiInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => { + const identifier = input.identifier.trim(); + return this.startBackgroundWorkflow( + () => generateOobiOp({ identifier, role: input.role }), + { + requestId: options.requestId, + label: `Generating ${input.role} OOBI for ${identifier}`, + title: `Generate ${input.role} OOBI`, + description: + input.role === 'agent' + ? 'Authorizes the agent endpoint role if needed, then fetches an identifier OOBI.' + : 'Fetches witnessed identifier OOBIs from KERIA.', + kind: 'generateOobi', + resourceKeys: [`oobi:${identifier}:${input.role}`], + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'OOBI generated', + message: `Generated a ${input.role} OOBI for ${identifier}.`, + severity: 'success', + }, + failureNotification: { + title: 'OOBI generation failed', + message: `The ${input.role} OOBI generation for ${identifier} failed.`, + severity: 'error', + }, + } + ); + }; + + startResolveContact = ( + input: ResolveContactInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => { + const oobi = input.oobi.trim(); + const alias = aliasForOobiResolution(oobi, input.alias); + const resourceKeys = [`contact:oobi:${oobi}`]; + if (alias !== null) { + resourceKeys.push(`contact:alias:${alias}`); + } + + return this.startBackgroundWorkflow( + () => resolveContactOobiOp({ oobi, alias }), + { + requestId: options.requestId, + label: + alias === null + ? 'Resolving contact OOBI' + : `Resolving contact ${alias}`, + title: 'Resolve contact OOBI', + description: + 'Submits an OOBI to KERIA and refreshes contact inventory after the operation completes.', + kind: 'resolveContact', + resourceKeys, + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'Contact resolved', + message: + alias === null + ? 'The contact OOBI resolved successfully.' + : `${alias} resolved successfully.`, + severity: 'success', + }, + failureNotification: { + title: 'Contact resolution failed', + message: 'The OOBI resolution failed.', + severity: 'error', + }, + } + ); + }; + + startDeleteContact = ( + contactId: string, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => deleteContactOp(contactId), { + requestId: options.requestId, + label: `Deleting contact ${contactId}`, + title: 'Delete contact', + description: 'Deletes a KERIA contact and refreshes inventory.', + kind: 'deleteContact', + resourceKeys: [`contact:${contactId}`], + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'Contact deleted', + message: `${contactId} was deleted.`, + severity: 'success', + }, + failureNotification: { + title: 'Contact deletion failed', + message: `${contactId} could not be deleted.`, + severity: 'error', + }, + }); + + startUpdateContactAlias = ( + input: UpdateContactAliasInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => updateContactAliasOp(input), { + requestId: options.requestId, + label: `Updating contact ${input.contactId}`, + title: 'Update contact alias', + description: 'Updates local KERIA contact metadata.', + kind: 'updateContact', + resourceKeys: [`contact:${input.contactId}`], + resultRoute: { label: 'View contacts', path: '/contacts' }, + successNotification: { + title: 'Contact updated', + message: `${input.contactId} was updated.`, + severity: 'success', + }, + failureNotification: { + title: 'Contact update failed', + message: `${input.contactId} could not be updated.`, + severity: 'error', + }, + }); + + generateContactChallenge = async ( + input: GenerateContactChallengeInput, + options: Pick = {} + ): Promise => + this.runWorkflow(() => generateContactChallengeOp(input), { + ...options, + kind: 'generateChallenge', + track: false, + }); + + startRespondToChallenge = ( + input: RespondToContactChallengeInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => respondToContactChallengeOp(input), { + requestId: options.requestId, + label: `Sending challenge response to ${input.counterpartyAid}`, + title: 'Send challenge response', + description: + 'Signs the challenge words with the selected identifier and sends the response to the contact.', + kind: 'respondChallenge', + resourceKeys: [ + `challenge:respond:${input.counterpartyAid}:${input.localIdentifier}:${input.challengeId ?? 'current'}`, + ], + resultRoute: challengeResultRoute(input.counterpartyAid), + successNotification: { + title: 'Challenge response sent', + message: 'The signed challenge response was sent.', + severity: 'success', + }, + failureNotification: { + title: 'Challenge response failed', + message: 'The challenge response could not be sent.', + severity: 'error', + }, + }); + + startSendChallengeRequest = ( + input: SendChallengeRequestInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => sendChallengeRequestOp(input), { + requestId: options.requestId, + label: `Sending challenge request to ${input.counterpartyAid}`, + title: 'Send challenge request', + description: + 'Sends a challenge request notification without embedding the challenge words.', + kind: 'sendChallengeRequest', + resourceKeys: [ + `challenge:request:${input.counterpartyAid}:${input.localIdentifier}:${input.challengeId}`, + ], + resultRoute: challengeResultRoute(input.counterpartyAid), + successNotification: { + title: 'Challenge request sent', + message: + 'The contact was notified that a challenge response is requested.', + severity: 'success', + }, + failureNotification: { + title: 'Challenge request failed', + message: + 'The challenge words remain available, but the notification could not be sent.', + severity: 'error', + }, + }); + + startVerifyContactChallenge = ( + input: VerifyContactChallengeInput, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => verifyContactChallengeOp(input), { + requestId: options.requestId, + label: `Waiting for challenge response from ${input.counterpartyAid}`, + title: 'Verify challenge response', + description: + 'Waits for a matching challenge response, accepts the response SAID, and refreshes contact inventory.', + kind: 'verifyChallenge', + resourceKeys: [ + `challenge:verify:${input.counterpartyAid}:${input.challengeId}`, + ], + resultRoute: challengeResultRoute(input.counterpartyAid), + successNotification: { + title: 'Challenge verified', + message: 'The contact challenge response was accepted.', + severity: 'success', + }, + failureNotification: { + title: 'Challenge verification failed', + message: 'The challenge response was not verified.', + severity: 'error', + }, + }); + + dismissExchangeNotification = async ( + input: DismissExchangeNotificationInput, + options: Pick = {} + ): Promise => + this.runWorkflow(() => dismissExchangeNotificationOp(input), { + ...options, + kind: 'workflow', + track: false, + }); + /** * Start a non-blocking workflow and return accepted/conflict metadata. * @@ -656,9 +1114,24 @@ export class AppRuntime { options: BackgroundWorkflowRunOptions ): Promise => { try { - await task; + const result = await task; + const payloadDetails = payloadDetailsFromWorkflowResult(result); + if (payloadDetails.length > 0) { + this.store.dispatch( + operationPayloadDetailsRecorded({ + requestId, + payloadDetails, + }) + ); + } this.store.dispatch(operationSucceeded({ requestId })); - this.recordCompletionNotification(requestId, options, 'success'); + this.recordCompletionNotification( + requestId, + options, + 'success', + undefined, + payloadDetails + ); } catch (error) { if (isHaltedOrAborted(error)) { this.store.dispatch( @@ -696,7 +1169,8 @@ export class AppRuntime { requestId: string, options: BackgroundWorkflowRunOptions, outcome: 'success' | 'error', - error?: string + error?: string, + payloadDetails: PayloadDetailRecord[] = [] ): void => { const template = outcome === 'success' @@ -737,6 +1211,7 @@ export class AppRuntime { readAt: null, operationId: requestId, links, + payloadDetails, }; this.store.dispatch(appNotificationRecorded(notification)); @@ -793,6 +1268,7 @@ export class AppRuntime { ); this.flushPersistence(); + await this.stopLiveSync(); for (const task of this.activeTasks.values()) { await task.halt(); } @@ -834,6 +1310,52 @@ export class AppRuntime { } }; + private startLiveSync = (): void => { + if (this.liveSyncTask !== null) { + void this.liveSyncTask.halt(); + } + + const task = this.scopes.run(() => liveSessionInventoryOp(), 'session'); + this.liveSyncTask = task; + + void (async () => { + try { + await task; + } catch (error) { + if (!isHaltedOrAborted(error)) { + this.store.dispatch( + appNotificationRecorded({ + id: `live-sync-failed-${Date.now()}`, + severity: 'warning', + status: 'unread', + title: 'Live inventory sync stopped', + message: toErrorText(error), + createdAt: new Date().toISOString(), + readAt: null, + operationId: null, + links: [], + payloadDetails: [], + }) + ); + } + } finally { + if (this.liveSyncTask === task) { + this.liveSyncTask = null; + } + } + })(); + }; + + private stopLiveSync = async (): Promise => { + const task = this.liveSyncTask; + if (task === null) { + return; + } + + this.liveSyncTask = null; + await task.halt(); + }; + private setPersistenceController = (controllerAid: string | null): void => { if (controllerAid === this.currentControllerAid) { return; diff --git a/src/config.ts b/src/config.ts index 6910a558..613f334a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,6 +66,8 @@ export interface OperationConfig { minSleepMs: number; /** Maximum polling interval passed to Signify's operation waiter. */ maxSleepMs: number; + /** Session inventory polling interval for dashboard/contact facts. */ + liveRefreshMs: number; } export interface WitnessConfig { @@ -278,6 +280,11 @@ export const buildAppConfig = (runtimeEnv: RuntimeEnv): AppConfig => { runtimeEnv.VITE_OPERATION_MAX_SLEEP_MS, 5000 ), + liveRefreshMs: numberFromEnv( + 'VITE_LIVE_REFRESH_MS', + runtimeEnv.VITE_LIVE_REFRESH_MS, + 3000 + ), }, witnesses: { aids: csvFromEnv( diff --git a/src/features/contacts/ContactDetailView.tsx b/src/features/contacts/ContactDetailView.tsx new file mode 100644 index 00000000..04201bad --- /dev/null +++ b/src/features/contacts/ContactDetailView.tsx @@ -0,0 +1,926 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Button, + Divider, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteIcon from '@mui/icons-material/Delete'; +import SaveIcon from '@mui/icons-material/Save'; +import SendIcon from '@mui/icons-material/Send'; +import ShieldIcon from '@mui/icons-material/Shield'; +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; +import { + Link as RouterLink, + useFetcher, + useLoaderData, + useNavigate, + useParams, +} from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, + TelemetryRow, +} from '../../app/Console'; +import { monoValueSx } from '../../app/consoleStyles'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { + ContactActionData, + ContactsLoaderData, +} from '../../app/routeData'; +import type { ContactRecord } from '../../state/contacts.slice'; +import type { ChallengeRecord } from '../../state/challenges.slice'; +import { useAppSelector } from '../../state/hooks'; +import { + selectChallengesForContact, + selectContactById, + selectIdentifiers, + selectStoredChallengeWordsForContact, +} from '../../state/selectors'; +import { + contactChallengeStatus, + contactOobiGroups, + contactOobiRoleSummary, +} from './contactHelpers'; +import type { ContactOobiGroup } from './contactHelpers'; +import { parseChallengeWords, validateChallengeWords } from './challengeWords'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +const copyText = (value: string): void => { + void globalThis.navigator?.clipboard?.writeText(value); +}; + +const resolutionTone = ( + status: ContactRecord['resolutionStatus'] +): 'success' | 'warning' | 'error' | 'neutral' => + status === 'error' + ? 'error' + : status === 'resolving' + ? 'warning' + : status === 'resolved' + ? 'success' + : 'neutral'; + +export const ContactDetailView = () => { + const loaderData = useLoaderData() as ContactsLoaderData; + const { contactId = '' } = useParams(); + const navigate = useNavigate(); + const fetcher = useFetcher(); + const challengeFetcher = useFetcher(); + const responseFetcher = useFetcher(); + const contact = useAppSelector(selectContactById(contactId)); + const challenges = useAppSelector(selectChallengesForContact(contactId)); + const storedChallengeWords = useAppSelector( + selectStoredChallengeWordsForContact(contactId) + ); + const identifiers = useAppSelector(selectIdentifiers); + const [aliasDraft, setAliasDraft] = useState({ + contactId, + value: contact?.alias ?? '', + }); + const [selectedIdentifier, setSelectedIdentifier] = useState(''); + const [responseWordsDraft, setResponseWordsDraft] = useState(''); + const actionRunning = fetcher.state !== 'idle'; + const challengeRunning = challengeFetcher.state !== 'idle'; + const responseRunning = responseFetcher.state !== 'idle'; + const activeIdentifier = selectedIdentifier || identifiers[0]?.name || ''; + const activeIdentifierSummary = + identifiers.find( + (identifier) => identifier.name === activeIdentifier + ) ?? null; + const responseWords = parseChallengeWords(responseWordsDraft); + const responseWordsError = + responseWordsDraft.trim().length === 0 + ? null + : validateChallengeWords(responseWords); + const responseWordsInvalid = validateChallengeWords(responseWords) !== null; + + useEffect(() => { + if (fetcher.data?.ok === true && fetcher.data.intent === 'delete') { + navigate('/contacts'); + } + }, [fetcher.data, navigate]); + + if (loaderData.status === 'blocked') { + return ; + } + + const submitAliasUpdate = () => { + if (contact === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'updateAlias'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.id); + formData.set('alias', draftAlias); + fetcher.submit(formData, { method: 'post' }); + }; + + const submitDelete = () => { + if (contact === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'delete'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.id); + fetcher.submit(formData, { method: 'post' }); + }; + + const submitGenerateChallenge = () => { + if (contact === null || activeIdentifierSummary === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'generateChallenge'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.aid ?? contact.id); + formData.set('contactAlias', contact.alias); + formData.set('localIdentifier', activeIdentifierSummary.name); + formData.set('localAid', activeIdentifierSummary.prefix); + challengeFetcher.submit(formData, { method: 'post' }); + }; + + const submitRetryVerifyChallenge = (challenge: { + challengeId: string; + counterpartyAid: string; + counterpartyAlias?: string | null; + localIdentifier: string; + localAid?: string | null; + words: readonly string[]; + wordsHash: string; + generatedAt: string; + }) => { + const formData = new FormData(); + formData.set('intent', 'verifyChallenge'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('challengeId', challenge.challengeId); + formData.set('contactId', challenge.counterpartyAid); + formData.set('contactAlias', challenge.counterpartyAlias ?? ''); + formData.set('localIdentifier', challenge.localIdentifier); + formData.set('localAid', challenge.localAid ?? ''); + formData.set('words', challenge.words.join(' ')); + formData.set('wordsHash', challenge.wordsHash); + formData.set('generatedAt', challenge.generatedAt); + challengeFetcher.submit(formData, { method: 'post' }); + }; + + const submitRespondChallenge = () => { + if (contact === null || activeIdentifierSummary === null) { + return; + } + + const formData = new FormData(); + formData.set('intent', 'respondChallenge'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contact.aid ?? contact.id); + formData.set('contactAlias', contact.alias); + formData.set('localIdentifier', activeIdentifierSummary.name); + formData.set('localAid', activeIdentifierSummary.prefix); + formData.set('words', responseWordsDraft); + responseFetcher.submit(formData, { method: 'post' }); + }; + + if (contact === null) { + return ( + + } + > + Back to contacts + + } + /> + + + ); + } + + const roleSummary = contactOobiRoleSummary(contact); + const oobiGroups = contactOobiGroups(contact); + const challengeStatus = contactChallengeStatus(contact); + const Shield = + challengeStatus.status === 'verified' ? ShieldIcon : ShieldOutlinedIcon; + const shieldColor = + challengeStatus.status === 'verified' + ? 'success.main' + : challengeStatus.status === 'pending' + ? 'warning.main' + : 'text.disabled'; + const aid = contact.aid ?? contact.id; + const draftAlias = + aliasDraft.contactId === contact.id ? aliasDraft.value : contact.alias; + const generatedChallengeCandidate = + challengeFetcher.data?.ok === true && + challengeFetcher.data.intent === 'generateChallenge' + ? challengeFetcher.data.challenge + : null; + const generatedChallengeVerified = + generatedChallengeCandidate !== null && + challenges.some( + (challenge) => + (challenge.id === generatedChallengeCandidate.challengeId || + challenge.wordsHash === + generatedChallengeCandidate.wordsHash) && + (challenge.authenticated || challenge.status === 'verified') + ); + const generatedChallenge = generatedChallengeVerified + ? null + : generatedChallengeCandidate; + const generatedChallengePhrase = + generatedChallenge === null ? null : generatedChallenge.words.join(' '); + const savedChallengeWords = storedChallengeWords.filter( + (record) => record.challengeId !== generatedChallenge?.challengeId + ); + const canUseChallenge = activeIdentifierSummary !== null && aid.length > 0; + + return ( + + } + > + Back to contacts + + } + /> + {loaderData.status === 'error' && ( + + {' '} + + {loaderData.message} + + + )} + {fetcher.data !== undefined && } + {challengeFetcher.data !== undefined && ( + + )} + {responseFetcher.data !== undefined && ( + + )} + + + + + + {challengeStatus.label} + + + + + + } + > + + + { + setAliasDraft({ + contactId: contact.id, + value: event.target.value, + }); + }} + fullWidth + size="small" + /> + + + + + + + + + + + + + + + + + + + + {contact.error !== null && ( + + {contact.error} + + )} + {oobiGroups.length > 0 && ( + + )} + + + + + + + {challengeStatus.label} + + + + } + > + + + + Identifier + + + + + + + + Generate challenge + + + {generatedChallengePhrase !== null && ( + + )} + {generatedChallenge !== null && ( + + Waiting operation{' '} + {generatedChallenge.challengeId} + + )} + {savedChallengeWords.length > 0 && ( + + + + Saved challenge words + + {savedChallengeWords.map((record) => ( + + + + + + {timestampText( + record.updatedAt + )} + + + + + + + ))} + + )} + + + + + + Respond to challenge + + { + setResponseWordsDraft( + event.target.value + ); + }} + minRows={3} + multiline + fullWidth + error={responseWordsError !== null} + helperText={ + responseWordsError ?? + `${responseWords.length} words` + } + data-testid="challenge-response-input" + /> + + + + + + + + {contact.endpoints.length === 0 ? ( + + ) : ( + + {contact.endpoints.map((endpoint) => ( + + + + + {endpoint.scheme} + + + + + {endpoint.eid} + + + ))} + + )} + + + + {contact.wellKnowns.length === 0 ? ( + + ) : ( + + {contact.wellKnowns.map((wellKnown) => ( + + + + {timestampText(wellKnown.dt)} + + + ))} + + )} + + + {challenges.length === 0 ? ( + + ) : ( + + {challenges.map((challenge) => ( + + ))} + + )} + + + + ); +}; + +const ActionNotice = ({ data }: { data: ContactActionData }) => ( + + {' '} + {data.message} + +); + +const CopyBlock = ({ + label, + value, + valueTestId, +}: { + label: string; + value: string; + valueTestId?: string; +}) => ( + + + + {label} + + + {value} + + + + { + copyText(value); + }} + > + + + + +); + +const FullOobiBlock = ({ groups }: { groups: readonly ContactOobiGroup[] }) => ( + + + Full OOBI + + + {groups.map((group) => ( + + + {group.label} + + + {group.oobis.map((oobi) => ( + + ))} + + + ))} + + +); + +const ChallengeBlock = ({ challenge }: { challenge: ChallengeRecord }) => ( + + + + + {timestampText(challenge.updatedAt)} + + + + + + +); diff --git a/src/features/contacts/ContactsView.tsx b/src/features/contacts/ContactsView.tsx new file mode 100644 index 00000000..fb5a41a6 --- /dev/null +++ b/src/features/contacts/ContactsView.tsx @@ -0,0 +1,711 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + FormControl, + IconButton, + InputLabel, + List, + ListItem, + ListItemText, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import LinkIcon from '@mui/icons-material/Link'; +import ShieldIcon from '@mui/icons-material/Shield'; +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; +import { + Link as RouterLink, + useFetcher, + useLoaderData, +} from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, +} from '../../app/Console'; +import { clickablePanelSx, monoValueSx } from '../../app/consoleStyles'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { + ContactActionData, + ContactsLoaderData, +} from '../../app/routeData'; +import type { ContactRecord } from '../../state/contacts.slice'; +import { useAppSelector } from '../../state/hooks'; +import { + selectContacts, + selectGeneratedOobis, + selectIdentifiers, +} from '../../state/selectors'; +import { + abbreviateMiddle, + aliasFromOobi, + contactChallengeStatus, + contactOobiRoleSummary, + identifierAvailableOobiRoles, + isWitnessContact, + type OobiGenerationRole, +} from './contactHelpers'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +const copyText = (value: string): void => { + void globalThis.navigator?.clipboard?.writeText(value); +}; + +const contactDetailPath = (contactId: string): string => + `/contacts/${encodeURIComponent(contactId)}`; + +const byAlias = (left: ContactRecord, right: ContactRecord): number => + left.alias.localeCompare(right.alias, undefined, { + sensitivity: 'base', + numeric: true, + }) || + (left.aid ?? left.id).localeCompare(right.aid ?? right.id, undefined, { + sensitivity: 'base', + numeric: true, + }); + +const roleOptionLabel = (role: OobiGenerationRole): string => + role === 'agent' ? 'Agent' : 'Witness'; + +const resolutionTone = ( + status: ContactRecord['resolutionStatus'] +): 'success' | 'warning' | 'error' | 'neutral' => + status === 'error' + ? 'error' + : status === 'resolving' + ? 'warning' + : status === 'resolved' + ? 'success' + : 'neutral'; + +export const ContactsView = () => { + const loaderData = useLoaderData() as ContactsLoaderData; + const fetcher = useFetcher(); + const identifiers = useAppSelector(selectIdentifiers); + const contacts = useAppSelector(selectContacts); + const generatedOobis = useAppSelector(selectGeneratedOobis); + const [oobi, setOobi] = useState(''); + const [alias, setAlias] = useState(''); + const [aliasTouched, setAliasTouched] = useState(false); + const [selectedIdentifier, setSelectedIdentifier] = useState(''); + const [selectedRole, setSelectedRole] = + useState('agent'); + const [pendingGenerateCopy, setPendingGenerateCopy] = useState<{ + id: string; + submittedAt: string; + } | null>(null); + const [pendingResolveClear, setPendingResolveClear] = useState<{ + oobi: string; + alias: string; + submittedAt: string; + } | null>(null); + const autoCopiedGeneratedOobis = useRef(new Set()); + const actionRunning = fetcher.state !== 'idle'; + const activeIdentifier = selectedIdentifier || identifiers[0]?.name || ''; + const activeIdentifierSummary = + identifiers.find((identifier) => identifier.name === activeIdentifier) ?? + null; + const roleOptions = useMemo( + () => identifierAvailableOobiRoles(activeIdentifierSummary), + [activeIdentifierSummary] + ); + const effectiveRole = roleOptions.includes(selectedRole) + ? selectedRole + : (roleOptions[0] ?? 'agent'); + const roleSelectValue = roleOptions.length === 0 ? '' : effectiveRole; + const effectiveAlias = aliasTouched ? alias : (aliasFromOobi(oobi) ?? ''); + const sortedContacts = useMemo( + () => [...contacts].sort(byAlias), + [contacts] + ); + const regularContacts = sortedContacts.filter( + (contact) => !isWitnessContact(contact) + ); + const witnessContacts = sortedContacts.filter(isWitnessContact); + + useEffect(() => { + if (pendingGenerateCopy === null) { + return undefined; + } + + const record = generatedOobis.find( + (candidate) => + candidate.id === pendingGenerateCopy.id && + candidate.generatedAt >= pendingGenerateCopy.submittedAt + ); + const firstOobi = record?.oobis[0]; + if (record === undefined || firstOobi === undefined) { + return undefined; + } + + const copyKey = `${record.id}:${record.generatedAt}:${firstOobi}`; + if (autoCopiedGeneratedOobis.current.has(copyKey)) { + return undefined; + } + + autoCopiedGeneratedOobis.current.add(copyKey); + copyText(firstOobi); + const clearPending = globalThis.setTimeout(() => { + setPendingGenerateCopy(null); + }, 0); + + return () => { + globalThis.clearTimeout(clearPending); + }; + }, [generatedOobis, pendingGenerateCopy]); + + useEffect(() => { + if (pendingResolveClear === null) { + return undefined; + } + + const resolved = contacts.some( + (contact) => + contact.resolutionStatus === 'resolved' && + (contact.oobi === pendingResolveClear.oobi || + (pendingResolveClear.alias.length > 0 && + contact.alias === pendingResolveClear.alias)) && + (contact.updatedAt ?? '') >= pendingResolveClear.submittedAt + ); + if (!resolved) { + return undefined; + } + + const clearForm = globalThis.setTimeout(() => { + setOobi(''); + setAlias(''); + setAliasTouched(false); + setPendingResolveClear(null); + }, 0); + + return () => { + globalThis.clearTimeout(clearForm); + }; + }, [contacts, pendingResolveClear]); + + if (loaderData.status === 'blocked') { + return ; + } + + const actionStatus = + fetcher.data === undefined + ? loaderData.status === 'error' + ? { ok: false, message: loaderData.message } + : null + : fetcher.data; + + const submitResolve = () => { + const submittedAt = new Date().toISOString(); + const requestId = globalThis.crypto.randomUUID(); + const formData = new FormData(); + formData.set('intent', 'resolve'); + formData.set('requestId', requestId); + formData.set('oobi', oobi); + formData.set('alias', effectiveAlias); + setPendingResolveClear({ + oobi: oobi.trim(), + alias: effectiveAlias.trim(), + submittedAt, + }); + fetcher.submit(formData, { method: 'post' }); + }; + + const submitGenerate = () => { + const submittedAt = new Date().toISOString(); + const requestId = globalThis.crypto.randomUUID(); + const formData = new FormData(); + formData.set('intent', 'generateOobi'); + formData.set('requestId', requestId); + formData.set('identifier', activeIdentifier); + formData.set('role', effectiveRole); + setPendingGenerateCopy({ + id: `${activeIdentifier}:${effectiveRole}`, + submittedAt, + }); + fetcher.submit(formData, { method: 'post' }); + }; + + const submitDelete = (contactId: string) => { + const formData = new FormData(); + formData.set('intent', 'delete'); + formData.set('requestId', globalThis.crypto.randomUUID()); + formData.set('contactId', contactId); + fetcher.submit(formData, { method: 'post' }); + }; + + return ( + + + {actionStatus !== null && ( + + {' '} + {actionStatus.message} + + )} + + + + { + setOobi(event.target.value); + }} + fullWidth + multiline + minRows={2} + data-testid="contact-oobi-input" + /> + { + setAliasTouched(true); + setAlias(event.target.value); + }} + fullWidth + data-testid="contact-alias-input" + /> + + + + + + + + Identifier + + + + + Role + + + + {generatedOobis.length > 0 && ( + + {generatedOobis.map((record) => ( + + + + {timestampText( + record.generatedAt + )} + + {record.oobis.map( + (generated) => ( + + + {generated} + + + { + copyText( + generated + ); + }} + > + + + + + ) + )} + + } + /> + + ))} + + )} + + + + + {contacts.length === 0 ? ( + + ) : ( + + {regularContacts.length === 0 ? ( + + Only witness contacts are currently known. Expand + the witnesses section below to inspect them. + + ) : ( + + )} + {witnessContacts.length > 0 && ( + + } + aria-controls="witness-contacts-content" + id="witness-contacts-header" + > + + + Witnesses + + + {witnessContacts.length} known witness + {witnessContacts.length === 1 + ? '' + : 'es'} + + + + + + + + )} + + )} + + + ); +}; + +const ContactGrid = ({ + contacts, + actionRunning, + onDelete, +}: { + contacts: readonly ContactRecord[]; + actionRunning: boolean; + onDelete: (contactId: string) => void; +}) => ( + + {contacts.map((contact) => ( + + ))} + +); + +const ContactCard = ({ + contact, + actionRunning, + onDelete, +}: { + contact: ContactRecord; + actionRunning: boolean; + onDelete: (contactId: string) => void; +}) => { + const roleSummary = contactOobiRoleSummary(contact); + const challengeStatus = contactChallengeStatus(contact); + const Shield = challengeStatus.status === 'verified' ? ShieldIcon : ShieldOutlinedIcon; + const shieldColor = + challengeStatus.status === 'verified' + ? 'success.main' + : challengeStatus.status === 'pending' + ? 'warning.main' + : 'text.disabled'; + const aid = contact.aid ?? contact.id; + + return ( + + + + + + {contact.alias} + + + {roleSummary.label} + + + + + + + + {abbreviateMiddle(aid, 36)} + + {contact.oobi !== null && ( + + {abbreviateMiddle(contact.oobi, 44)} + + )} + + + + {contact.endpoints.length} endpoints ·{' '} + {contact.wellKnowns.length} well-knowns + + + + + {contact.oobi !== null && ( + + { + copyText(contact.oobi ?? ''); + }} + > + + + + )} + + + { + onDelete(contact.id); + }} + > + + + + + + + ); +}; diff --git a/src/features/contacts/challengeWords.ts b/src/features/contacts/challengeWords.ts new file mode 100644 index 00000000..7d533a4b --- /dev/null +++ b/src/features/contacts/challengeWords.ts @@ -0,0 +1,56 @@ +/** Supported BIP39 challenge strengths exposed by KERIA. */ +export type ChallengeStrength = 128 | 256; + +/** Parse pasted challenge text into normalized words. */ +export const parseChallengeWords = (value: string): string[] => + value + .trim() + .split(/\s+/u) + .map((word) => word.trim().toLowerCase()) + .filter((word) => word.length > 0); + +/** User-facing validation for challenge word lists. */ +export const validateChallengeWords = ( + words: readonly string[] +): string | null => { + if (words.length === 0) { + return 'Challenge words are required.'; + } + + if (words.length !== 12 && words.length !== 24) { + return 'Challenge must contain 12 or 24 words.'; + } + + return null; +}; + +/** Validate and return normalized words, throwing for workflow boundaries. */ +export const requireChallengeWords = (value: string): string[] => { + const words = parseChallengeWords(value); + const error = validateChallengeWords(words); + if (error !== null) { + throw new Error(error); + } + + return words; +}; + +/** + * Stable non-secret fingerprint for challenge words. + * + * This is used only for local matching and operation conflict keys so raw + * challenge phrases do not leak into persisted operation records. + */ +export const challengeWordsFingerprint = (words: readonly string[]): string => { + let hash = 0x811c9dc5; + const text = words.join(' '); + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + + return (hash >>> 0).toString(16).padStart(8, '0'); +}; + +/** Default user-facing challenge strength: 12 mnemonic words. */ +export const defaultChallengeStrength: ChallengeStrength = 128; diff --git a/src/features/contacts/contactHelpers.ts b/src/features/contacts/contactHelpers.ts new file mode 100644 index 00000000..f16008f7 --- /dev/null +++ b/src/features/contacts/contactHelpers.ts @@ -0,0 +1,618 @@ +import type { Contact } from 'signify-ts'; +import type { + ContactEndpoint, + ContactEndpointRole, + ContactRecord, + ContactWellKnown, +} from '../../state/contacts.slice'; +import type { ChallengeRecord } from '../../state/challenges.slice'; +import type { IdentifierSummary } from '../identifiers/identifierTypes'; +import { challengeWordsFingerprint } from './challengeWords'; + +export const CONTACT_ENDPOINT_ROLES = [ + 'agent', + 'controller', + 'witness', + 'registrar', + 'watcher', + 'judge', + 'juror', + 'peer', + 'mailbox', +] as const satisfies readonly ContactEndpointRole[]; + +export type OobiGenerationRole = 'agent' | 'witness'; + +export type ContactChallengeDisplayStatus = + | 'verified' + | 'pending' + | 'unverified'; + +export interface ContactOobiRoleSummary { + primaryRole: ContactEndpointRole | null; + roles: ContactEndpointRole[]; + label: string; +} + +export interface ContactChallengeStatusSummary { + status: ContactChallengeDisplayStatus; + label: string; + tooltip: string; +} + +export interface ContactOobiGroup { + role: ContactEndpointRole; + label: string; + oobis: string[]; +} + +const CONTACT_ENDPOINT_ROLE_SET = new Set(CONTACT_ENDPOINT_ROLES); +const CONTACT_OOBI_DETAIL_ROLES = [ + 'agent', + 'witness', + 'mailbox', + 'controller', +] as const satisfies readonly ContactEndpointRole[]; +const CONTACT_OOBI_DETAIL_ROLE_SET = new Set( + CONTACT_OOBI_DETAIL_ROLES +); + +const roleLabels: Record = { + agent: 'Agent', + controller: 'Controller', + witness: 'Witness', + registrar: 'Registrar', + watcher: 'Watcher', + judge: 'Judge', + juror: 'Juror', + peer: 'Peer', + mailbox: 'Mailbox', +}; + +const normalizeEndpointRole = ( + role: string | null +): ContactEndpointRole | null => + role !== null && CONTACT_ENDPOINT_ROLE_SET.has(role) + ? (role as ContactEndpointRole) + : null; + +const pushUniqueRole = ( + roles: ContactEndpointRole[], + role: ContactEndpointRole | null +): void => { + if (role !== null && !roles.includes(role)) { + roles.push(role); + } +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const stringValue = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value : null; + +const endpointScheme = (url: string): string => { + try { + return new URL(url).protocol.replace(':', '') || 'unknown'; + } catch { + return 'unknown'; + } +}; + +const endpointRoleRecords = ( + role: ContactEndpointRole, + value: unknown +): ContactEndpoint[] => { + if (!isRecord(value)) { + return []; + } + + return Object.entries(value).flatMap(([eid, rawEndpoint]) => { + const directUrl = stringValue(rawEndpoint); + if (directUrl !== null) { + return [ + { + role, + eid, + scheme: endpointScheme(directUrl), + url: directUrl, + }, + ]; + } + + if (!isRecord(rawEndpoint)) { + return []; + } + + return Object.entries(rawEndpoint).flatMap(([scheme, rawUrl]) => { + const url = stringValue(rawUrl); + if (url === null) { + return []; + } + + return [ + { + role, + eid, + scheme, + url, + }, + ]; + }); + }); +}; + +const webBaseUrl = (url: string): string | null => { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + return parsed.toString().replace(/\/+$/, ''); + } catch { + return null; + } +}; + +const endpointOobiUrl = ( + contact: ContactRecord, + endpoint: ContactEndpoint +): string | null => { + const baseUrl = webBaseUrl(endpoint.url); + if (baseUrl === null) { + return null; + } + + const contactAid = contact.aid ?? contact.id; + switch (endpoint.role) { + case 'agent': + return `${baseUrl}/oobi/${contactAid}/agent/${endpoint.eid}`; + case 'witness': + return `${baseUrl}/oobi/${endpoint.eid}/controller?tag=witness`; + case 'controller': + return `${baseUrl}/oobi/${endpoint.eid}/controller`; + case 'mailbox': + return `${baseUrl}/oobi/${contactAid}/mailbox/${endpoint.eid}`; + default: + return null; + } +}; + +/** + * Extract a useful alias from an OOBI URL query string when one is present. + */ +export const aliasFromOobi = (oobi: string): string | null => { + try { + return stringValue(new URL(oobi).searchParams.get('name')); + } catch { + return null; + } +}; + +/** + * Extract the resolved AID from common KERIA OOBI URL paths. + */ +export const aidFromOobi = (oobi: string | null | undefined): string | null => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return null; + } + + try { + const url = new URL(oobi); + const parts = url.pathname.split('/').filter(Boolean); + const oobiIndex = parts.indexOf('oobi'); + return oobiIndex >= 0 ? stringValue(parts[oobiIndex + 1]) : null; + } catch { + return null; + } +}; + +/** + * Remove app-local alias hints before handing an OOBI to KERIA. + */ +export const normalizeOobiUrlForResolution = (oobi: string): string => { + const trimmed = oobi.trim(); + try { + const url = new URL(trimmed); + url.searchParams.delete('name'); + return url.toString(); + } catch { + return trimmed; + } +}; + +/** + * Prefer a user-entered alias, then the OOBI `name` query hint. + */ +export const aliasForOobiResolution = ( + oobi: string, + alias: string | null | undefined +): string | null => { + const trimmedAlias = stringValue(alias); + return trimmedAlias ?? aliasFromOobi(oobi); +}; + +/** + * Stable placeholder id used while KERIA resolution is still in flight. + */ +export const pendingContactIdForOobi = ( + oobi: string, + alias: string | null | undefined +): string => `pending:${aliasForOobiResolution(oobi, alias) ?? oobi.trim()}`; + +/** + * Extract likely component tags from KERIA OOBI URLs. + */ +export const componentTagsFromOobi = ( + oobi: string | null | undefined +): string[] => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return []; + } + + try { + const url = new URL(oobi); + const tags = new Set(); + const queryTag = stringValue(url.searchParams.get('tag')); + const queryRole = stringValue(url.searchParams.get('role')); + if (queryTag !== null) { + tags.add(queryTag); + } + if (queryRole !== null) { + tags.add(queryRole); + } + + const parts = url.pathname.split('/').filter(Boolean); + const oobiIndex = parts.indexOf('oobi'); + const role = oobiIndex >= 0 ? parts[oobiIndex + 2] : null; + if (role !== null && role !== undefined) { + tags.add(role); + } + + return [...tags]; + } catch { + return []; + } +}; + +/** + * Extract endpoint-role candidates from an OOBI, preserving source priority. + * + * Query tags are more semantically useful for witness/controller OOBIs: local + * witness URLs commonly use `/controller?role=witness`, where the role label + * users care about is "Witness" rather than "Controller". + */ +export const endpointRolesFromOobi = ( + oobi: string | null | undefined +): ContactEndpointRole[] => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return []; + } + + try { + const url = new URL(oobi); + const roles: ContactEndpointRole[] = []; + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('tag'))) + ); + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('role'))) + ); + + const parts = url.pathname.split('/').filter(Boolean); + const oobiIndex = parts.indexOf('oobi'); + const pathRole = + oobiIndex >= 0 ? stringValue(parts[oobiIndex + 2]) : null; + pushUniqueRole(roles, normalizeEndpointRole(pathRole)); + + return roles; + } catch { + return []; + } +}; + +/** + * Extract only explicit role metadata from OOBI query hints. + * + * This is intentionally narrower than `endpointRolesFromOobi`: a regular + * witnessed identifier can carry `ends.witness` records, but that does not make + * the contact itself a witness component. + */ +export const explicitOobiMetadataRoles = ( + oobi: string | null | undefined +): ContactEndpointRole[] => { + if (oobi === null || oobi === undefined || oobi.trim().length === 0) { + return []; + } + + try { + const url = new URL(oobi); + const roles: ContactEndpointRole[] = []; + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('tag'))) + ); + pushUniqueRole( + roles, + normalizeEndpointRole(stringValue(url.searchParams.get('role'))) + ); + return roles; + } catch { + return []; + } +}; + +/** + * Group full OOBI URLs by role for the contact detail view. + */ +export const contactOobiGroups = ( + contact: ContactRecord +): ContactOobiGroup[] => { + const groups = new Map>(); + const addOobi = (role: ContactEndpointRole | null, oobi: string | null) => { + if ( + role === null || + oobi === null || + !CONTACT_OOBI_DETAIL_ROLE_SET.has(role) + ) { + return; + } + + const existing = groups.get(role) ?? new Set(); + existing.add(oobi); + groups.set(role, existing); + }; + + if (contact.oobi !== null) { + const sourceRoles = explicitOobiMetadataRoles(contact.oobi); + const roles = + sourceRoles.length > 0 + ? sourceRoles + : endpointRolesFromOobi(contact.oobi); + for (const role of roles) { + addOobi(role, contact.oobi); + } + } + + for (const endpoint of contact.endpoints) { + addOobi(endpoint.role, endpointOobiUrl(contact, endpoint)); + } + + return CONTACT_OOBI_DETAIL_ROLES.flatMap((role) => { + const oobis = [...(groups.get(role) ?? [])]; + return oobis.length === 0 + ? [] + : [ + { + role, + label: `${roleLabels[role]} OOBI`, + oobis, + }, + ]; + }); +}; + +/** + * Keep long OOBIs and AIDs recognizable without letting them dominate cards. + */ +export const abbreviateMiddle = (value: string, maxLength = 56): string => { + if (value.length <= maxLength) { + return value; + } + + const edgeLength = Math.max(8, Math.floor((maxLength - 3) / 2)); + return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; +}; + +/** + * Summarize the most relevant OOBI/endpoint role for compact contact cards. + */ +export const contactOobiRoleSummary = ( + contact: ContactRecord +): ContactOobiRoleSummary => { + const roles: ContactEndpointRole[] = []; + for (const role of endpointRolesFromOobi(contact.oobi)) { + pushUniqueRole(roles, role); + } + for (const endpoint of contact.endpoints) { + pushUniqueRole(roles, endpoint.role); + } + for (const tag of contact.componentTags) { + pushUniqueRole(roles, normalizeEndpointRole(tag)); + } + + const primaryRole = roles[0] ?? null; + if (primaryRole === null) { + return { + primaryRole, + roles, + label: 'Unknown OOBI', + }; + } + + const extraCount = roles.length - 1; + return { + primaryRole, + roles, + label: `${roleLabels[primaryRole]} OOBI${extraCount > 0 ? ` +${extraCount}` : ''}`, + }; +}; + +/** + * Witness records are useful, but they should not crowd the main contact list. + */ +export const isWitnessContact = (contact: ContactRecord): boolean => + explicitOobiMetadataRoles(contact.oobi).includes('witness'); + +/** + * Shield state for compact contact cards and contact detail headers. + */ +export const contactChallengeStatus = ( + contact: ContactRecord +): ContactChallengeStatusSummary => { + if (contact.authenticatedChallengeCount > 0) { + return { + status: 'verified', + label: 'Verified', + tooltip: `${contact.authenticatedChallengeCount} authenticated challenge${contact.authenticatedChallengeCount === 1 ? '' : 's'}`, + }; + } + + if (contact.challengeCount > 0) { + return { + status: 'pending', + label: 'Challenge pending', + tooltip: `${contact.challengeCount} challenge response${contact.challengeCount === 1 ? '' : 's'} known, none authenticated`, + }; + } + + return { + status: 'unverified', + label: 'Unverified', + tooltip: 'No authenticated challenge is known for this contact', + }; +}; + +/** + * Available local OOBI generation roles for a managed identifier. + */ +export const identifierAvailableOobiRoles = ( + identifier: IdentifierSummary | null | undefined +): OobiGenerationRole[] => { + if (identifier === null || identifier === undefined) { + return []; + } + + const witnessBackers = (identifier as { state?: { b?: unknown } }).state?.b; + const hasWitnessBackers = + Array.isArray(witnessBackers) && + witnessBackers.some((backer) => typeof backer === 'string'); + const hasWitnessIndexes = + Array.isArray(identifier.windexes) && identifier.windexes.length > 0; + + return hasWitnessBackers || hasWitnessIndexes + ? ['agent', 'witness'] + : ['agent']; +}; + +const normalizeWellKnowns = (contact: Contact): ContactWellKnown[] => + (contact.wellKnowns ?? []).flatMap((item) => { + const url = stringValue(item.url); + const dt = stringValue(item.dt); + if (url === null || dt === null) { + return []; + } + + return [{ url, dt }]; + }); + +const normalizeEndpoints = (contact: Contact): ContactEndpoint[] => + CONTACT_ENDPOINT_ROLES.flatMap((role) => + endpointRoleRecords(role, contact.ends?.[role]) + ); + +/** + * Project KERIA's loose contact shape into serializable app state. + */ +export const contactRecordFromKeriaContact = ( + contact: Contact, + updatedAt: string +): ContactRecord => { + const alias = stringValue(contact.alias) ?? contact.id; + const oobi = stringValue(contact.oobi); + const challenges = contact.challenges ?? []; + + return { + id: contact.id, + alias, + aid: contact.id, + oobi, + endpoints: normalizeEndpoints(contact), + wellKnowns: normalizeWellKnowns(contact), + componentTags: componentTagsFromOobi(oobi), + challengeCount: challenges.length, + authenticatedChallengeCount: challenges.filter( + (challenge) => challenge.authenticated === true + ).length, + resolutionStatus: 'resolved', + error: null, + updatedAt, + }; +}; + +/** + * Convert contact challenge arrays into read-only dashboard challenge records. + */ +export const challengeRecordsFromKeriaContacts = ( + contacts: Contact[], + updatedAt: string +): ChallengeRecord[] => + contacts.flatMap((contact) => + (contact.challenges ?? []).map((challenge, index) => { + const said = stringValue(challenge.said); + const authenticated = challenge.authenticated === true; + const updated = stringValue(challenge.dt) ?? updatedAt; + return { + id: `${contact.id}:${said ?? index.toString()}`, + source: 'keria', + direction: 'received', + role: stringValue(contact.alias) ?? contact.id, + counterpartyAid: contact.id, + words: challenge.words, + wordsHash: challengeWordsFingerprint(challenge.words), + responseSaid: said, + authenticated, + status: authenticated ? 'verified' : 'responded', + result: said, + error: null, + verifiedAt: authenticated ? updated : null, + updatedAt: updated, + }; + }) + ); + +export interface KnownComponentRecord { + id: string; + contactId: string; + alias: string; + role: string; + eid: string | null; + scheme: string | null; + url: string | null; + source: 'endpoint' | 'oobi'; +} + +/** + * Derive witness/watcher/mailbox/etc. records from contact endpoints and OOBIs. + */ +export const knownComponentsFromContacts = ( + contacts: readonly ContactRecord[] +): KnownComponentRecord[] => + contacts.flatMap((contact) => { + const endpointComponents = contact.endpoints.map((endpoint) => ({ + id: `${contact.id}:endpoint:${endpoint.role}:${endpoint.eid}`, + contactId: contact.id, + alias: contact.alias, + role: endpoint.role, + eid: endpoint.eid, + scheme: endpoint.scheme, + url: endpoint.url, + source: 'endpoint' as const, + })); + + const tagComponents = contact.componentTags.map((tag) => ({ + id: `${contact.id}:oobi:${tag}`, + contactId: contact.id, + alias: contact.alias, + role: tag, + eid: contact.aid, + scheme: null, + url: contact.oobi, + source: 'oobi' as const, + })); + + return [...endpointComponents, ...tagComponents]; + }); diff --git a/src/features/dashboard/DashboardView.tsx b/src/features/dashboard/DashboardView.tsx new file mode 100644 index 00000000..736eb89d --- /dev/null +++ b/src/features/dashboard/DashboardView.tsx @@ -0,0 +1,470 @@ +import { + Box, + Divider, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from '@mui/material'; +import { Link as RouterLink, useLoaderData } from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, + TelemetryRow, +} from '../../app/Console'; +import { monoValueSx } from '../../app/consoleStyles'; +import { clickablePanelSx } from '../../app/consoleStyles'; +import { useAppSession } from '../../app/runtimeHooks'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { DashboardLoaderData } from '../../app/routeData'; +import { useAppSelector } from '../../state/hooks'; +import { + selectDashboardCounts, + selectKnownComponentsByRole, + selectRecentAppNotifications, + selectRecentChallenges, + selectRecentKeriaNotifications, + selectRecentOperations, + selectSession, +} from '../../state/selectors'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +const CountTile = ({ + label, + value, + to, +}: { + label: string; + value: number; + to: string; +}) => ( + + + {label} + + + {value} + + +); + +export const DashboardView = () => { + const loaderData = useLoaderData() as DashboardLoaderData; + const runtimeSnapshot = useAppSession(); + const session = useAppSelector(selectSession); + const counts = useAppSelector(selectDashboardCounts); + const recentOperations = useAppSelector(selectRecentOperations(5)); + const recentKeriaNotifications = useAppSelector( + selectRecentKeriaNotifications(5) + ); + const recentAppNotifications = useAppSelector(selectRecentAppNotifications(5)); + const recentChallenges = useAppSelector(selectRecentChallenges(5)); + const componentGroups = Array.from( + useAppSelector(selectKnownComponentsByRole).entries() + ).sort(([left], [right]) => left.localeCompare(right)); + const connection = runtimeSnapshot.connection; + + if (loaderData.status === 'blocked') { + return ; + } + + const keriaTarget = + connection.status === 'connected' ? connection.client.url : 'Disconnected'; + + return ( + + + {loaderData.status === 'error' && ( + + {' '} + + {loaderData.message} + + + )} + + + + + + + + + + + + + + + + + + + + {componentGroups.length === 0 ? ( + + ) : ( + + {componentGroups.map(([role, components]) => ( + + + + {role} + + + + + {components.slice(0, 6).map( + (component) => ( + + + {component.url ?? + component.eid ?? + component.contactId} + + } + /> + + ) + )} + + + + ))} + + )} + + + + + + {recentOperations.length === 0 ? ( + + ) : ( + + {recentOperations.map((operation) => ( + + + + {operation.title} + + + + } + secondary={timestampText( + operation.startedAt + )} + /> + + ))} + + )} + + + {recentKeriaNotifications.length === 0 ? ( + + ) : ( + + {recentKeriaNotifications.map((notification) => ( + + + + {notification.route} + + + + } + secondary={timestampText( + notification.updatedAt + )} + /> + + ))} + + )} + + + {recentAppNotifications.length === 0 ? ( + + ) : ( + + {recentAppNotifications.map((notification) => ( + + + + ))} + + )} + + + {recentChallenges.length === 0 ? ( + + ) : ( + + {recentChallenges.map((challenge) => ( + + + + {challenge.role} + + + + } + secondary={timestampText( + challenge.updatedAt + )} + /> + + ))} + + )} + + + + ); +}; diff --git a/src/features/identifiers/IdentifierDetailsModal.tsx b/src/features/identifiers/IdentifierDetailsModal.tsx index 7b7b5acd..6d4138b4 100644 --- a/src/features/identifiers/IdentifierDetailsModal.tsx +++ b/src/features/identifiers/IdentifierDetailsModal.tsx @@ -6,15 +6,20 @@ import { Box, Button, Chip, + CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, + IconButton, Stack, + Tooltip, Typography, } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import RotateRightIcon from '@mui/icons-material/RotateRight'; +import type { GeneratedOobiRecord } from '../../state/contacts.slice'; import type { IdentifierSummary } from './identifierTypes'; import { formatIdentifierMetadata, @@ -36,11 +41,18 @@ export interface IdentifierDetailsModalProps { identifier: IdentifierSummary | null; refreshStatus: 'idle' | 'loading' | 'success' | 'error'; refreshMessage: string | null; + oobiState: IdentifierOobiDetailState; actionRunning: boolean; onClose: () => void; onRotate: (name: string) => void; } +export interface IdentifierOobiDetailState { + status: 'idle' | 'loading' | 'success' | 'error'; + message: string | null; + records: GeneratedOobiRecord[]; +} + interface DetailFieldProps { label: string; value: string; @@ -198,6 +210,10 @@ const JsonCodeBlock = ({ value }: { value: string }) => ( ); +const copyValue = (value: string): void => { + void globalThis.navigator.clipboard?.writeText(value); +}; + /** * Identifier details and rotate action. * @@ -209,6 +225,7 @@ export const IdentifierDetailsModal = ({ identifier, refreshStatus, refreshMessage, + oobiState, actionRunning, onClose, onRotate, @@ -350,6 +367,23 @@ export const IdentifierDetailsModal = ({ {refreshMessage} )} + + }> + + OOBIs + {oobiState.status === 'loading' && ( + + )} + + + + + + {identifier !== null && ( }> @@ -395,3 +429,99 @@ export const IdentifierDetailsModal = ({ ); }; + +const IdentifierOobis = ({ state }: { state: IdentifierOobiDetailState }) => { + if (state.status === 'loading' || state.status === 'idle') { + return ( + + Loading identifier OOBIs... + + ); + } + + if (state.status === 'error') { + return ( + + Unable to load identifier OOBIs: {state.message} + + ); + } + + if (state.records.length === 0) { + return ( + + No OOBIs are available for this identifier. + + ); + } + + return ( + + {state.records.map((record) => ( + + + + + + {record.oobis.length} URL + {record.oobis.length === 1 ? '' : 's'} + + + {record.oobis.map((oobi) => ( + + + {oobi} + + + copyValue(oobi)} + > + + + + + ))} + + + ))} + + ); +}; diff --git a/src/features/identifiers/IdentifierTable.tsx b/src/features/identifiers/IdentifierTable.tsx index 776fc5d2..27171ba9 100644 --- a/src/features/identifiers/IdentifierTable.tsx +++ b/src/features/identifiers/IdentifierTable.tsx @@ -16,6 +16,7 @@ import { Tooltip, Typography, } from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import RotateRightIcon from '@mui/icons-material/RotateRight'; import type { IdentifierSummary } from './identifierTypes'; import { @@ -34,6 +35,13 @@ export interface IdentifierTableProps { onSelect: (identifier: IdentifierSummary) => void; onRotate: (name: string) => void; isRotateDisabled: (identifier: IdentifierSummary) => boolean; + onCopyAgentOobi: (identifier: IdentifierSummary) => void; + agentOobiCopyStatus: Record; +} + +export interface IdentifierOobiCopyStatus { + status: 'idle' | 'loading' | 'success' | 'error'; + message: string | null; } const monoSx = { @@ -41,6 +49,37 @@ const monoSx = { letterSpacing: 0, }; +const tableHeadBg = 'rgba(39, 215, 255, 0.06)'; + +const compactOnlyActionSx = { + display: { sm: 'inline-flex', lg: 'none' }, +} as const; + +const mdUpCellSx = { + display: { sm: 'none', md: 'table-cell' }, +} as const; + +const lgUpCellSx = { + display: { sm: 'none', lg: 'table-cell' }, +} as const; + +const stickyActionCellSx = { + position: 'sticky', + right: 0, + zIndex: 2, + width: { sm: 104, lg: 72 }, + minWidth: { sm: 104, lg: 72 }, + bgcolor: 'background.paper', + borderLeft: 1, + borderLeftColor: 'divider', +} as const; + +const stickyActionHeadCellSx = { + ...stickyActionCellSx, + zIndex: 4, + bgcolor: tableHeadBg, +} as const; + interface CopyableMonoValueProps { value: string; label: string; @@ -69,10 +108,15 @@ const CopyableMonoValue = ({ bgcolor: 'transparent', color: copied ? 'success.main' : 'primary.main', cursor: 'copy', + display: 'inline-block', fontSize: 'inherit', lineHeight: 'inherit', textAlign: 'left', maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'clip', + verticalAlign: 'bottom', + whiteSpace: 'nowrap', ...monoSx, }} aria-label={`Copy ${label} ${value}`} @@ -93,6 +137,8 @@ export const IdentifierTable = ({ onSelect, onRotate, isRotateDisabled, + onCopyAgentOobi, + agentOobiCopyStatus, }: IdentifierTableProps) => { const [copiedValue, setCopiedValue] = useState(null); @@ -114,6 +160,14 @@ export const IdentifierTable = ({ onRotate(identifier.name); }; + const copyAgentOobi = ( + event: MouseEvent, + identifier: IdentifierSummary + ) => { + event.stopPropagation(); + onCopyAgentOobi(identifier); + }; + return ( @@ -179,7 +233,19 @@ export const IdentifierTable = ({ - + + - +
- Name - AID - Type - KIDX - PIDX - Actions + + Name + + + AID + + + Type + + + KIDX + + + PIDX + + + OOBI + + + Actions + {identifiers.map((identifier) => ( onSelect(identifier)} > - - {identifier.name} + + + {identifier.name} + - + - + {identifierType(identifier)} - + {formatIdentifierMetadata( identifierKeyIndex(identifier) )} - + {formatIdentifierMetadata( identifierIdentifierIndex(identifier) )} - - - - - rotate(event, identifier) + + + + + + + - - - - + onCopy={copyAgentOobi} + size="small" + /> + + + + + rotate(event, identifier) + } + > + + + + + ))} @@ -287,3 +438,58 @@ export const IdentifierTable = ({ ); }; + +const oobiCopyTooltip = ( + status: IdentifierOobiCopyStatus | undefined +): string => { + if (status?.status === 'loading') { + return 'Fetching agent OOBI...'; + } + + if (status?.status === 'success') { + return 'Copied agent OOBI'; + } + + if (status?.status === 'error') { + return status.message ?? 'Unable to copy agent OOBI'; + } + + return 'Copy agent OOBI'; +}; + +const OobiCopyButton = ({ + identifier, + copyStatus, + onCopy, + size = 'medium', +}: { + identifier: IdentifierSummary; + copyStatus: IdentifierOobiCopyStatus | undefined; + onCopy: ( + event: MouseEvent, + identifier: IdentifierSummary + ) => void; + size?: 'small' | 'medium'; +}) => ( + + + onCopy(event, identifier)} + > + + + + +); diff --git a/src/features/identifiers/IdentifiersView.tsx b/src/features/identifiers/IdentifiersView.tsx index 0a708a2a..9b32ac36 100644 --- a/src/features/identifiers/IdentifiersView.tsx +++ b/src/features/identifiers/IdentifiersView.tsx @@ -10,19 +10,30 @@ import type { IdentifiersLoaderData, } from '../../app/routeData'; import { IdentifierCreateDialog } from './IdentifierCreateDialog'; -import { IdentifierDetailsModal } from './IdentifierDetailsModal'; -import { IdentifierTable } from './IdentifierTable'; +import { + IdentifierDetailsModal, + type IdentifierOobiDetailState, +} from './IdentifierDetailsModal'; +import { + IdentifierTable, + type IdentifierOobiCopyStatus, +} from './IdentifierTable'; import { idleIdentifierAction, type IdentifierActionState, type IdentifierCreateDraft, type IdentifierSummary, } from './identifierTypes'; +import type { GeneratedOobiRecord } from '../../state/contacts.slice'; import { useAppSelector } from '../../state/hooks'; import { selectActiveOperations, selectIdentifiers, } from '../../state/selectors'; +import { + identifierAvailableOobiRoles, + type OobiGenerationRole, +} from '../contacts/contactHelpers'; /** * Connected identifiers feature route. @@ -47,6 +58,14 @@ export const IdentifiersView = () => { status: 'idle' | 'loading' | 'success' | 'error'; message: string | null; }>({ status: 'idle', message: null }); + const [detailOobis, setDetailOobis] = useState({ + status: 'idle', + message: null, + records: [], + }); + const [agentOobiCopyStatus, setAgentOobiCopyStatus] = useState< + Record + >({}); const actionRunning = fetcher.state !== 'idle'; const liveIdentifiers = useAppSelector(selectIdentifiers); const activeOperations = useAppSelector(selectActiveOperations); @@ -79,27 +98,56 @@ export const IdentifiersView = () => { const controller = new AbortController(); - void runtime - .getIdentifier(selectedIdentifierName, { - signal: controller.signal, - track: false, - }) - .then(() => { + void (async () => { + try { + const refreshed = await runtime.getIdentifier( + selectedIdentifierName, + { + signal: controller.signal, + track: false, + } + ); + if (controller.signal.aborted) { + return; + } + + setDetailRefresh({ status: 'success', message: null }); + + const roles = identifierAvailableOobiRoles(refreshed); + const records = await runtime.listIdentifierOobis( + refreshed.name, + roles, + { + signal: controller.signal, + track: false, + } + ); if (!controller.signal.aborted) { - setDetailRefresh({ status: 'success', message: null }); + setDetailOobis({ + status: 'success', + message: null, + records, + }); } - }) - .catch((error: unknown) => { + } catch (error: unknown) { if (controller.signal.aborted) { return; } - setDetailRefresh({ + const message = + error instanceof Error ? error.message : String(error); + setDetailRefresh((current) => + current.status === 'loading' + ? { status: 'error', message } + : current + ); + setDetailOobis({ status: 'error', - message: - error instanceof Error ? error.message : String(error), + message, + records: [], }); - }); + } + })(); return () => { controller.abort(); @@ -176,9 +224,70 @@ export const IdentifiersView = () => { }; const handleSelectIdentifier = (identifier: IdentifierSummary) => { setDetailRefresh({ status: 'loading', message: null }); + setDetailOobis({ status: 'loading', message: null, records: [] }); setSelectedIdentifierName(identifier.name); }; + const copyGeneratedOobi = async ( + identifier: IdentifierSummary, + role: OobiGenerationRole + ): Promise => { + const record = await runtime.getIdentifierOobi( + { + identifier: identifier.name, + role, + }, + { track: false } + ); + const firstOobi = record.oobis[0]; + if (firstOobi === undefined) { + throw new Error(`No ${role} OOBI returned for ${identifier.name}.`); + } + + await globalThis.navigator.clipboard?.writeText(firstOobi); + return record; + }; + + const handleCopyAgentOobi = (identifier: IdentifierSummary) => { + setAgentOobiCopyStatus((current) => ({ + ...current, + [identifier.name]: { status: 'loading', message: null }, + })); + + void copyGeneratedOobi(identifier, 'agent') + .then(() => { + setAgentOobiCopyStatus((current) => ({ + ...current, + [identifier.name]: { status: 'success', message: null }, + })); + globalThis.setTimeout(() => { + setAgentOobiCopyStatus((current) => + current[identifier.name]?.status === 'success' + ? { + ...current, + [identifier.name]: { + status: 'idle', + message: null, + }, + } + : current + ); + }, 1800); + }) + .catch((error: unknown) => { + setAgentOobiCopyStatus((current) => ({ + ...current, + [identifier.name]: { + status: 'error', + message: + error instanceof Error + ? error.message + : String(error), + }, + })); + }); + }; + return ( { isRotateDisabled={(identifier) => isRotateDisabled(identifier.name) } + onCopyAgentOobi={handleCopyAgentOobi} + agentOobiCopyStatus={agentOobiCopyStatus} /> { identifier={selectedIdentifier} refreshStatus={detailRefresh.status} refreshMessage={detailRefresh.message} + oobiState={detailOobis} actionRunning={ selectedIdentifierName === null ? false @@ -282,6 +394,11 @@ export const IdentifiersView = () => { onClose={() => { setSelectedIdentifierName(null); setDetailRefresh({ status: 'idle', message: null }); + setDetailOobis({ + status: 'idle', + message: null, + records: [], + }); }} onRotate={handleRotate} /> diff --git a/src/features/notifications/AppNotificationsView.tsx b/src/features/notifications/AppNotificationsView.tsx index 0f5a3272..dc8cf231 100644 --- a/src/features/notifications/AppNotificationsView.tsx +++ b/src/features/notifications/AppNotificationsView.tsx @@ -4,18 +4,22 @@ import { Link, List, ListItem, + ListItemButton, ListItemText, Stack, Typography, } from '@mui/material'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink, useLoaderData } from 'react-router-dom'; import { ConsolePanel, EmptyState, PageHeader, StatusPill, } from '../../app/Console'; +import { PayloadDetails } from '../../app/PayloadDetails'; import { formatTimestamp } from '../../app/timeFormat'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import type { NotificationsLoaderData } from '../../app/routeData'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { allAppNotificationsRead } from '../../state/appNotifications.slice'; import { @@ -26,6 +30,7 @@ import { const APP_NOTIFICATION_READ_DELAY_MS = 1250; export const AppNotificationsView = () => { + const loaderData = useLoaderData() as NotificationsLoaderData; const dispatch = useAppDispatch(); const notifications = useAppSelector(selectAppNotifications); const keriaNotifications = useAppSelector(selectKeriaNotifications); @@ -47,13 +52,34 @@ export const AppNotificationsView = () => { }; }, [dispatch, unreadCount]); + if (loaderData.status === 'blocked') { + return ; + } + return ( + {loaderData.status === 'error' && ( + + {' '} + + {loaderData.message} + + + )} {notifications.length === 0 ? ( { > {notification.message} + { ))} )} - {keriaNotifications.length > 0 && ( - + + {keriaNotifications.length === 0 ? ( + + ) : ( {keriaNotifications.map((notification) => ( - { {notification.message} )} + {notification.challengeRequest !== + null && + notification.challengeRequest !== + undefined && ( + + From{' '} + { + notification + .challengeRequest + .senderAlias + } + + )} } /> - + ))} - - )} + )} + ); }; diff --git a/src/features/notifications/ChallengeRequestResponseForm.tsx b/src/features/notifications/ChallengeRequestResponseForm.tsx new file mode 100644 index 00000000..6285114e --- /dev/null +++ b/src/features/notifications/ChallengeRequestResponseForm.tsx @@ -0,0 +1,204 @@ +import { useMemo, useState } from 'react'; +import { + Box, + Button, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import { useFetcher } from 'react-router-dom'; +import type { ContactActionData } from '../../app/routeData'; +import type { IdentifierSummary } from '../identifiers/identifierTypes'; +import type { ChallengeRequestNotification } from '../../state/notifications.slice'; +import { + parseChallengeWords, + validateChallengeWords, +} from '../contacts/challengeWords'; +import { defaultChallengeResponseIdentifierName } from './challengeRequestFormHelpers'; + +const createRequestId = (): string => + globalThis.crypto?.randomUUID?.() ?? + `challenge-response-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +export interface ChallengeRequestResponseFormProps { + request: ChallengeRequestNotification; + identifiers: readonly IdentifierSummary[]; + action?: string; + dense?: boolean; +} + +export const ChallengeRequestResponseForm = ({ + request, + identifiers, + action = '/notifications', + dense = false, +}: ChallengeRequestResponseFormProps) => { + const fetcher = useFetcher(); + const [requestId] = useState(createRequestId); + const [selectedIdentifier, setSelectedIdentifier] = useState(''); + const [wordsDraft, setWordsDraft] = useState(''); + const defaultIdentifier = defaultChallengeResponseIdentifierName( + { recipientAid: request.recipientAid }, + identifiers + ); + const activeIdentifier = selectedIdentifier || defaultIdentifier; + const activeIdentifierSummary = + identifiers.find( + (identifier) => identifier.name === activeIdentifier + ) ?? null; + const words = useMemo(() => parseChallengeWords(wordsDraft), [wordsDraft]); + const wordsError = + wordsDraft.trim().length === 0 ? null : validateChallengeWords(words); + const wordsInvalid = validateChallengeWords(words) !== null; + const running = fetcher.state !== 'idle'; + const actionable = request.status === 'actionable'; + const disabled = + !actionable || + running || + activeIdentifierSummary === null || + wordsInvalid; + + return ( + + + + + + + + + + + + + + Identifier + + + + + { + setWordsDraft(event.target.value); + }} + minRows={dense ? 2 : 6} + multiline + fullWidth + error={wordsError !== null} + helperText={wordsError ?? `${words.length} words`} + disabled={!actionable || running} + data-testid="challenge-notification-response-input" + /> + {dense ? ( + + + + + + + + ) : ( + + )} + + {fetcher.data !== undefined && ( + + {fetcher.data.message} + + )} + + + ); +}; diff --git a/src/features/notifications/NotificationDetailView.tsx b/src/features/notifications/NotificationDetailView.tsx new file mode 100644 index 00000000..aa225133 --- /dev/null +++ b/src/features/notifications/NotificationDetailView.tsx @@ -0,0 +1,286 @@ +import { useEffect } from 'react'; +import { + Box, + Button, + Divider, + IconButton, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { + Link as RouterLink, + useFetcher, + useLoaderData, + useNavigate, + useParams, +} from 'react-router-dom'; +import { ConnectionRequired } from '../../app/ConnectionRequired'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, + TelemetryRow, +} from '../../app/Console'; +import { formatTimestamp } from '../../app/timeFormat'; +import type { + ContactActionData, + NotificationsLoaderData, +} from '../../app/routeData'; +import { useAppSelector } from '../../state/hooks'; +import { + selectChallengeRequestNotificationById, + selectIdentifiers, + selectKeriaNotificationById, +} from '../../state/selectors'; +import { ChallengeRequestResponseForm } from './ChallengeRequestResponseForm'; + +const timestampText = (value: string | null): string => + value === null ? 'Not available' : (formatTimestamp(value) ?? value); + +export const NotificationDetailView = () => { + const loaderData = useLoaderData() as NotificationsLoaderData; + const { notificationId = '' } = useParams(); + const navigate = useNavigate(); + const dismissFetcher = useFetcher(); + const notification = useAppSelector( + selectKeriaNotificationById(notificationId) + ); + const challengeRequest = useAppSelector( + selectChallengeRequestNotificationById(notificationId) + ); + const identifiers = useAppSelector(selectIdentifiers); + + useEffect(() => { + if ( + dismissFetcher.data?.ok === true && + dismissFetcher.data.intent === 'dismissExchangeNotification' + ) { + navigate('/notifications'); + } + }, [dismissFetcher.data, navigate]); + + if (loaderData.status === 'blocked') { + return ; + } + + if (notification === null) { + return ( + + } + > + Back to notifications + + } + /> + + + ); + } + + return ( + + + {challengeRequest !== null && ( + + + { + const formData = new FormData(); + formData.set( + 'intent', + 'dismissExchangeNotification' + ); + formData.set( + 'requestId', + globalThis.crypto.randomUUID() + ); + formData.set( + 'notificationId', + notification.id + ); + formData.set( + 'exnSaid', + challengeRequest.exnSaid + ); + formData.set( + 'route', + notification.route + ); + dismissFetcher.submit(formData, { + method: 'post', + action: `/notifications/${encodeURIComponent( + notification.id + )}`, + }); + }} + > + + + + + )} + + + } + /> + {loaderData.status === 'error' && ( + + {' '} + + {loaderData.message} + + + )} + {challengeRequest !== null ? ( + + } + > + + + + + + + + + + + {challengeRequest.status === 'senderUnknown' ? ( + + ) : ( + + )} + + + ) : ( + + + + + + + + {notification.message !== null && ( + + )} + + + )} + + ); +}; diff --git a/src/features/notifications/challengeRequestFormHelpers.ts b/src/features/notifications/challengeRequestFormHelpers.ts new file mode 100644 index 00000000..4106c065 --- /dev/null +++ b/src/features/notifications/challengeRequestFormHelpers.ts @@ -0,0 +1,18 @@ +import type { ChallengeRequestNotification } from '../../state/notifications.slice'; +import type { IdentifierSummary } from '../identifiers/identifierTypes'; + +export const defaultChallengeResponseIdentifierName = ( + request: Pick, + identifiers: readonly IdentifierSummary[] +): string => { + if (request.recipientAid !== null) { + const recipientIdentifier = identifiers.find( + (identifier) => identifier.prefix === request.recipientAid + ); + if (recipientIdentifier !== undefined) { + return recipientIdentifier.name; + } + } + + return identifiers[0]?.name ?? ''; +}; diff --git a/src/features/operations/OperationDetailView.tsx b/src/features/operations/OperationDetailView.tsx index 0096f21e..a23a6cc6 100644 --- a/src/features/operations/OperationDetailView.tsx +++ b/src/features/operations/OperationDetailView.tsx @@ -6,6 +6,7 @@ import { StatusPill, TelemetryRow, } from '../../app/Console'; +import { PayloadDetails } from '../../app/PayloadDetails'; import { formatTimestamp } from '../../app/timeFormat'; import { useAppSelector } from '../../state/hooks'; import { selectOperationById } from '../../state/selectors'; @@ -100,6 +101,11 @@ export const OperationDetailView = () => { } /> + {operation.payloadDetails.length > 0 && ( + + + + )}