diff --git a/README.md b/README.md index 40c4bb92..3ac467d2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ - [App shell and views](./docs/app-shell.md): typed view registry, default view, selector contracts, and how to add new app views. +- [Background operations and app notifications](./docs/background-operations-and-notifications.md): + non-blocking Effection workflows, operation history, resource conflicts, + notification UX, and how to add new background flows. +- [Local persistence](./docs/persistence.md): controller-AID-keyed local + storage for operations and app notifications, reload interruption semantics, + and maintainer change checklist. +- [Identifier UI](./docs/identifier-ui.md): identifier list/details display + rules, AID truncation/copy behavior, KIDX/PIDX/tier extraction, and rotate + affordances. - [Signify client boundary](./docs/signify-client-boundary.md): ownership, configuration, and public boundary API. - [Runtime config](./docs/runtime-config.md): app/runtime config ownership, diff --git a/docs/app-shell.md b/docs/app-shell.md index 429fee6e..aa87d03d 100644 --- a/docs/app-shell.md +++ b/docs/app-shell.md @@ -9,6 +9,8 @@ Feature UI remains under `src/features/*`. Feature components render loader and action state; they do not construct Signify clients or own route registration. For the Effection, service, workflow, and Redux state layers behind `AppRuntime`, see [Workflow and state architecture](./workflow-state-architecture.md). +For the background operation indicator and notification bell, see +[Background operations and app notifications](./background-operations-and-notifications.md). ## Runtime Boundary @@ -118,8 +120,8 @@ fields are `routeId`, `label`, `gate`, `nav`, and `testId`. `AppRouteId` -: Closed set of current feature route IDs: `identifiers`, `credentials`, and -`client`. +: Closed set of current feature route IDs: `identifiers`, `credentials`, +`client`, `operations`, and `appNotifications`. `AppRouteGate` @@ -132,13 +134,19 @@ their handles. ## Current Routes -| Path | Route behavior | Loader/action owner | Gating | -| -------------- | ------------------------------ | -------------------------- | --------------------------------- | -| `/` | redirects to `/identifiers` | root child index loader | none | -| `/identifiers` | identifier list/detail/create | identifiers loader/action | connected Signify client required | -| `/credentials` | connected placeholder | credentials loader | connected Signify client required | -| `/client` | client/controller/agent state | client loader | connected Signify state required | -| `*` | redirects to `/identifiers` | catch-all loader | none | +| Path | Route behavior | Loader/action owner | Gating | +|--------------------------|----------------------------------------|---------------------------|-----------------------------------| +| `/` | redirects to `/identifiers` | root child index loader | none | +| `/identifiers` | identifier list/detail/create/rotate | identifiers loader/action | connected Signify client required | +| `/credentials` | connected placeholder | credentials loader | connected Signify client required | +| `/client` | client/controller/agent state | client loader | connected Signify state required | +| `/operations` | persisted/background operation history | Redux selectors | none | +| `/operations/:requestId` | operation detail and result links | Redux selectors | none | +| `/notifications` | app-level user notifications | Redux selectors | none | +| `*` | redirects to `/identifiers` | catch-all loader | none | + +Operations and app-notification routes are intentionally ungated. Persisted +history should remain viewable after disconnect, refresh, or reconnect. Direct navigation to a gated route renders `ConnectionRequired` until the user connects. Routes do not auto-open the connect dialog. @@ -206,6 +214,9 @@ needs. handled by `RouteErrorBoundary`. - Mutations that affect a route's loader data should live in that route's action so React Router revalidation stays predictable. +- Background mutations return accepted/conflict action data immediately. They + should update visible route state through Redux completion facts rather than + relying only on route revalidation. - Future credential actions should use explicit intents such as `issue`, `grant`, `admit`, and `present`, not a generic command string. @@ -246,7 +257,9 @@ route descriptor and handle, not editing a second navigation registry. `TopBar` : Shell-only app bar with the stable `nav-open` and `connect-open` smoke-test -selectors. +selectors. It also renders the active-operation indicator and app notification +bell from Redux selectors. It must not know Signify clients or route loader +internals. ## Adding A Route diff --git a/docs/background-operations-and-notifications.md b/docs/background-operations-and-notifications.md new file mode 100644 index 00000000..703c240c --- /dev/null +++ b/docs/background-operations-and-notifications.md @@ -0,0 +1,140 @@ +# Background Operations And App Notifications + +This document explains the app's non-blocking operation model, app-level +notifications, operation history routes, and shell indicators. Use it when +moving KERIA work out of a blocking route action or when adding user-visible +completion/failure feedback for long-running work. + +## Mental Model + +The app has two workflow launch paths: + +| Path | Runtime API | UX contract | Examples | +| --- | --- | --- | --- | +| Foreground | `AppRuntime.runWorkflow(...)` | The user is blocked by route loading, a fetcher submission, or the global loading overlay until work finishes. | Connect, passcode generation, route loader reads. | +| Background | `AppRuntime.startBackgroundWorkflow(...)` | The route action returns immediately with accepted/conflict metadata while Effection watches the KERIA work in the session scope. | Identifier create and rotate. | + +Foreground work is still correct when the app cannot proceed without the +result. Background work is correct when the user can keep navigating while the +operation finishes and can inspect progress or completion later. + +Top-level background handoff belongs in `AppRuntime`. Use Effection `spawn` +inside a workflow only for true child concurrency within one unit of work. Do +not start top-level background tasks directly from React components or services; +that bypasses operation tracking, conflict checks, and notifications. + +```mermaid +flowchart TD + Action["Route action"] --> Runtime["AppRuntime.startBackgroundWorkflow"] + Runtime --> Conflict["resourceKeys conflict check"] + Conflict -->|conflict| ActionData["typed conflict action data"] + Conflict -->|accepted| Operations["operationStarted record"] + Operations --> Task["Effection task in session scope"] + Task --> Workflow["workflow/service/KERIA wait"] + Workflow --> DomainState["domain Redux state"] + Task --> Completion["operation success/error/cancel"] + Completion --> Notification["app notification"] + Completion --> Routes["/operations and /notifications"] +``` + +## Operation Records + +`src/state/operations.slice.ts` stores serializable operation facts. These +records power active-operation indicators, operation history, detail pages, +conflict guards, and persistence. + +Important fields: + +| Field | Purpose | +| --- | --- | +| `requestId` | Correlation key shared by route action response, operation detail route, and app notification. | +| `kind` | Machine-readable operation category. Add a typed kind before wiring new workflow families. | +| `phase` | Human-readable current phase. Today most flows use lifecycle status; future workflows can update finer phases. | +| `resourceKeys` | Optimistic concurrency keys used to reject or disable conflicting work. | +| `operationRoute` | Link to `/operations/:requestId`. | +| `resultRoute` | Optional link to the operation-specific success context. | +| `notificationId` | Back-reference to the app notification created for background completion/failure. | +| `keriaOperationName` | Optional raw KERIA operation name for diagnostics, never the raw operation object. | + +Redux must stay serializable. Do not store `Task`, `SignifyClient`, raw KERIA +operation responses, `Error`, `AbortController`, DOM nodes, or Promise objects +in any state slice. + +## Conflict Guarding + +Each background operation should declare resource keys before launch. The +runtime rejects a new background task when a running operation owns any of the +same keys. + +Current keys: + +- Identifier create: `identifier:name:` +- Identifier rotate: `identifier:aid:` + +Expected future keys: + +- Contact resolution: `contact:` +- Schema resolution: `schema:` +- Registry creation: `registry:issuer:` +- Credential flows: `credential:` + +The UI should disable only the conflicting action, not the whole app. For +example, rotating one identifier should disable only that identifier's rotate +button while allowing navigation and other non-conflicting actions. + +## App Notifications + +`src/state/appNotifications.slice.ts` is the user-facing notification system for +app work. It is separate from `src/state/notifications.slice.ts`, which tracks +KERIA notification inventory and processing status. + +An app notification is created by `AppRuntime.recordCompletionNotification(...)` +when a background task completes successfully or fails and the launch options +provide a notification template. + +Notification links: + +- `operation`: required link to `/operations/:requestId`. +- `result`: optional link to the operation-specific result route. + +Notification read behavior: + +- The bell popover in `TopBar` marks visible unread notifications as read after + 1250 ms. +- The `/notifications` route also marks unread notifications as read after + 1250 ms. +- `selectAppNotifications` returns notifications in descending `createdAt` + order. + +## Shell And Routes + +The app shell exposes background work through: + +- `TopBar` operation indicator: active count and popover of running operations. +- `TopBar` notification bell: unread count and recent app notifications. +- `/operations`: reverse-chronological operation history. +- `/operations/:requestId`: operation detail with lifecycle fields and links. +- `/notifications`: app notification list. + +Operations and notifications routes have no connection gate. Persisted history +must remain visible after disconnect or refresh. + +The global `LoadingOverlay` is for foreground work only: connect, passcode +generation, route navigation, and loader/fetcher pending state. Background +operations must not trigger the blocking overlay. + +## Adding A Background Flow + +1. Add a typed `OperationKind`. +2. Define resource keys before launch. +3. Add or extend an Effection service operation for Signify/KERIA calls. +4. Add a workflow that composes services and dispatches domain Redux state. +5. Add `AppRuntime.startX(...)` using `startBackgroundWorkflow(...)`. +6. Provide title, description, result route, and success/failure notification + templates. +7. Wire the route action to return accepted/conflict metadata immediately. +8. Use selectors to disable only conflicting UI actions. +9. Add reducer/runtime/route-data tests and a smoke check for the visible UX. + +Do not add a background path without a conflict key unless the operation truly +cannot conflict with any other valid user action. diff --git a/docs/identifier-ui.md b/docs/identifier-ui.md new file mode 100644 index 00000000..d840ff51 --- /dev/null +++ b/docs/identifier-ui.md @@ -0,0 +1,135 @@ +# Identifier UI + +This document explains the identifier list and details UI. Use it when changing +identifier display fields, rotation affordances, or Signify `HabState` +presentation helpers. + +## Data Model + +The app's identifier display model is `IdentifierSummary`, currently an alias +for Signify's `HabState`. + +Important display fields: + +| UI field | Source | Notes | +|-------------|-------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------| +| AID | `identifier.prefix` | Long values are shortened in the table, full value remains copyable. | +| Current Key | `identifier.state.k[0]` | The first current public key. Multiple keys are indicated in details and fully visible in Advanced JSON. | +| KIDX | `identifier.salty.kidx` | Exposed by salty identifiers. Do not derive it for other identifier types. | +| PIDX | `identifier.salty.pidx` | Exposed by salty identifiers. Do not derive it for other identifier types. | +| Tier | `identifier.salty.tier` | Exposed by salty identifiers. | +| Type | Tagged `HabState` branch: `salty`, `randy`, `group`, or `extern`. | Use shared helper extraction. | + +Randy, group, and extern identifiers do not currently expose the same local +key-manager metadata as salty identifiers. The UI should show an unavailable +placeholder for missing KIDX, PIDX, and tier values rather than deriving +misleading values from event sequence numbers. + +## Helper Ownership + +`src/features/identifiers/identifierHelpers.ts` owns identifier display +extraction: + +- `identifierType(...)` +- `identifierCurrentKeys(...)` +- `identifierCurrentKey(...)` +- `identifierKeyIndex(...)` +- `identifierIdentifierIndex(...)` +- `identifierTier(...)` +- `formatIdentifierMetadata(...)` +- `identifierJson(...)` +- `truncateMiddle(...)` + +Components should not duplicate Signify shape guessing. If a new identifier +branch exposes new display metadata, add it to the helpers and unit tests first, +then render it from components. + +## Details Surface + +`IdentifierDetailsModal` is the current identifier detail surface. There is no +dedicated `/identifiers/:id` route in this phase. + +The modal shows human-readable fields first: + +- Name +- AID +- Type +- Current Key +- Key Index +- Identifier Index +- Tier + +Full identifier JSON is hidden behind the `Advanced JSON` accordion. The JSON +highlighter is local and React-rendered. It must not use +`dangerouslySetInnerHTML`. + +The details modal keeps a rotate action, but route/runtime ownership remains in +`IdentifiersView`. The modal receives `onRotate`; it never calls Signify or the +runtime directly. + +## Identifier Table + +The desktop table columns are: + +- Name +- AID +- Current Key +- Type +- KIDX +- PIDX +- Actions + +Mobile cards mirror the same core fields and rotate action. + +The AID and current-key display contract: + +- use `truncateMiddle(aid)` as first eight characters, `...`, last eight + characters for long values, +- show the full value in a tooltip, +- copy the full value on click, +- stop click propagation so copying does not open details, +- use the shared `--app-mono-font` CSS variable. + +The table rotate button: + +- uses the rotate icon, +- stops click propagation so rotating does not open details, +- calls the parent `onRotate`, +- is disabled only for the row whose active operation owns + `identifier:aid:`. + +## Rotation And Fresh State + +Identifier rotation is a background operation. `IdentifiersView` submits the +route action, the runtime starts the Effection workflow, and the workflow +refreshes identifier Redux state after KERIA completion. + +Successful rotation must refresh local AID state so fields such as KIDX update +without requiring a manual route reload. If Signify/KERIA response shapes change +and include a fresh `HabState`, update the service layer and tests before +changing UI behavior. + +## Font + +`src/index.css` imports Source Code Pro from Google Fonts and defines: + +```css +--app-mono-font: 'Source Code Pro', 'Roboto Mono', 'SFMono-Regular', + Consolas, 'Liberation Mono', monospace; +``` + +Use this variable for AIDs, current keys, and JSON/code-like identifier data. +Do not introduce one-off monospace stacks in components. + +## Tests + +Relevant coverage: + +- `tests/unit/identifierHelpers.test.ts` covers salty and randy metadata + extraction, unavailable values, JSON formatting, and middle truncation. +- `tests/browser-smoke.mjs` checks that the table includes AID, KIDX, PIDX, and + Actions headers after connect. +- Scenario tests protect real Signify/KERIA rotation behavior. + +Run `pnpm unit:test`, `pnpm browser:smoke`, and `pnpm scenario:test` when +changing display helpers or rotation behavior. diff --git a/docs/persistence.md b/docs/persistence.md new file mode 100644 index 00000000..df9c9ba3 --- /dev/null +++ b/docs/persistence.md @@ -0,0 +1,117 @@ +# Local Persistence + +This document explains the local storage layer for operation history and +app-level notifications. Use it when changing persisted state shape, +controller switching behavior, or reload/interruption semantics. + +## Purpose + +The persistence layer eagerly stores serializable app work history so a browser +refresh, tab close, crash, or hot-module replacement does not erase the user's +most recent operation and notification context. + +Persistence is intentionally conservative. It preserves facts the app owns, but +it does not claim that KERIA operation watchers survive a page reload. A +previously running local watcher is rehydrated as interrupted so the user sees a +clear local-state explanation instead of a false completion state. + +## Storage Key + +Persisted state is keyed by Signify controller AID: + +```text +signify-react-ts:app-state:v1: +``` + +The helper is `persistedAppStateKey(controllerAid)` in +`src/state/persistence.ts`. + +Controller-AID keying is mandatory because the same browser can authenticate +multiple Signify controllers. Operation and notification history for one +controller must not appear under another controller. + +## Persisted Shape + +The stored JSON has this top-level shape: + +```ts +interface PersistedAppState { + version: 1; + operations: OperationRecord[]; + appNotifications: AppNotificationRecord[]; +} +``` + +Only serializable Redux records are persisted. Do not persist raw Signify +clients, Effection tasks, raw KERIA operation responses, `Error` objects, +abort controllers, functions, or DOM objects. + +Both operation and app-notification slices have bounded retention limits. The +persisted value mirrors the bounded Redux state. + +## Runtime Lifecycle + +`AppRuntime` owns persistence installation and controller switching. + +- Runtime construction calls `installAppStatePersistence(...)`. +- Store changes are saved only when `currentControllerAid` is known. +- Successful connect calls `setPersistenceController(controllerAid)`. +- Changing controller flushes the previous controller bucket before rehydrating + the new bucket. +- `disconnect()` flushes before clearing connection state. +- `destroy()` flushes before halting tasks and tearing down scopes. +- `App.tsx` calls `appRuntime.destroy()` on `pagehide` and Vite HMR disposal. + +Tests inject a memory implementation through the `AppStateStorage` interface. +Do not make persistence depend directly on browser `localStorage` in code that +unit tests need to exercise. + +## Rehydration Semantics + +`rehydratePersistedAppState(...)` loads the controller bucket and dispatches: + +- `operationsRehydrated(...)` +- `appNotificationsRehydrated(...)` + +Completed operation records rehydrate as they were stored. + +Running records rehydrate as: + +- `status: "interrupted"` +- `phase: "interrupted"` +- `finishedAt: ` +- `canceledReason: "Browser refresh stopped the local operation watcher."` + +This is a deliberate limitation. The browser-side Effection task that was +watching KERIA cannot be resumed after reload in this phase. A future resumable +watcher would need to persist enough KERIA operation identity and re-query +KERIA after reconnect before changing this behavior. + +## Invalid Data + +Invalid JSON, unsupported versions, and malformed records are ignored. The load +path filters individual operation and notification records with runtime guards +instead of trusting local storage. + +## Tests + +Persistence behavior is covered by `tests/unit/persistence.test.ts`: + +- save/load operation history, +- controller-AID bucket isolation, +- running operations rehydrate as interrupted, +- empty buckets clear current state, +- subscribed writes only save under the active controller, +- invalid stored data is ignored. + +## Change Checklist + +Update this document and tests when changing: + +- persistence version, +- storage key prefix, +- persisted top-level shape, +- operation interruption semantics, +- controller switching behavior, +- storage injection contract, +- retention limits for persisted slices. diff --git a/docs/workflow-state-architecture.md b/docs/workflow-state-architecture.md index 1c11fb18..5d2d0585 100644 --- a/docs/workflow-state-architecture.md +++ b/docs/workflow-state-architecture.md @@ -15,10 +15,10 @@ The architecture has four layers: | Workflows | `src/workflows/*.op.ts` | Effection operations that orchestrate services, handle route aborts, and dispatch Redux state changes. | | State | `src/state/*.slice.ts` | Serializable Redux projections for session, operations, identifiers, contacts, challenges, credentials, schemas, registries, roles, and notifications. | -React Router talks to `AppRuntime`. `AppRuntime` launches workflows and exposes -Promise-returning methods to loaders/actions. Components render route data and -Redux-derived shell state; they do not construct Signify clients or call -`signify-ts` directly. +React Router talks to `AppRuntime`. `AppRuntime` launches foreground workflows +for loaders/actions and background workflows for non-blocking KERIA work. +Components render route data and Redux-derived shell state; they do not +construct Signify clients or call `signify-ts` directly. ```mermaid flowchart TD @@ -37,8 +37,10 @@ flowchart TD ## Runtime And Effection `src/app/runtime.ts` is the bridge between React Router's Promise-facing API and -Effection's operation model. Public runtime methods such as `connect`, -`listIdentifiers`, and `createIdentifier` call `runWorkflow`. +Effection's operation model. Public runtime methods such as `connect` and +`listIdentifiers` call `runWorkflow` for foreground work. Background methods +such as `startCreateIdentifier` and `startRotateIdentifier` call +`startBackgroundWorkflow`. `runWorkflow` is responsible for: @@ -48,6 +50,21 @@ Effection's operation model. Public runtime methods such as `connect`, - wiring React Router abort signals into `task.halt()`, - reporting success, failure, or cancellation to `state.operations`. +`startBackgroundWorkflow` is responsible for: + +- rejecting resource-key conflicts before task launch, +- recording a running operation with operation/result routes, +- starting a session-scoped Effection task and returning immediately, +- watching task completion outside the route action, +- recording success/failure/cancellation, +- creating app notifications when templates are supplied. + +Use foreground workflows when route rendering cannot proceed without the +result. Use background workflows when the user can keep navigating while KERIA +finishes the work. Top-level background handoff belongs to `AppRuntime`; use +Effection `spawn` inside workflows only for child concurrency within one unit of +work. + Use `scope: "app"` for work that may run before or outside a KERIA session, such as passcode generation or initial connect. Use the default session scope for work that must be halted on disconnect or reconnect. @@ -122,12 +139,13 @@ State slices: | Slice | Purpose | | --- | --- | | `session` | Serializable connection state: status, boot flag, controller AID, agent AID, error, connected time. | -| `operations` | Runtime workflow lifecycle records for pending overlays, diagnostics, cancellation, and history. | +| `operations` | Runtime workflow lifecycle records for foreground diagnostics, background operation history, active conflict guards, cancellation, and persistence. | +| `appNotifications` | User-facing app notification records for operation completion/failure and shell notification UX. | | `identifiers` | Normalized identifier inventory and last identifier mutation. | | `contacts` | OOBI/contact resolution records. | | `challenges` | Challenge/response exchange records. | | `credentials` | Credential summary records by SAID. | -| `notifications` | Notification route processing status. | +| `notifications` | KERIA notification inventory and processing status. This is separate from app-level user notifications. | | `schema` | Credential schema resolution records. | | `registry` | Issuer registry records. | | `roles` | Local issuer/holder/verifier role bindings. | @@ -136,6 +154,11 @@ Selectors in `src/state/selectors.ts` are the preferred read API. Add selectors when a component or workflow needs a derived view of state; do not duplicate derivation in components. +Persisted operation and app notification records are documented in +[Local persistence](./persistence.md). The background operation and +app-notification lifecycle is documented in +[Background operations and app notifications](./background-operations-and-notifications.md). + ## Adding A New KERIA Flow Use this order: @@ -147,10 +170,13 @@ Use this order: 3. Add or update Redux slice records for durable, serializable progress. 4. Add an Effection workflow that calls the service and dispatches slice actions. -5. Add an `AppRuntime` method if React Router loaders/actions need the flow. -6. Add route loader/action wiring and UI rendering. -7. Add unit tests for parsing/reducers/runtime workflow behavior. -8. Add scenario tests only when the flow must prove real KERIA behavior. +5. Decide foreground versus background launch. Background flows must define + resource keys, operation metadata, result links, and notification templates + before UI wiring. +6. Add an `AppRuntime` method if React Router loaders/actions need the flow. +7. Add route loader/action wiring and UI rendering. +8. Add unit tests for parsing/reducers/runtime workflow behavior. +9. Add scenario tests only when the flow must prove real KERIA behavior. If a value is app/runtime configuration, add it to `src/config.ts`. If it is an optional external fixture only needed by Vitest, add it to diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index a272aa5d..1c1c81bd 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -10,7 +10,11 @@ import { LoadingOverlay } from './LoadingOverlay'; import { NavigationDrawer } from './NavigationDrawer'; import { TopBar } from './TopBar'; import { useAppSelector } from '../state/hooks'; -import { selectLatestActiveOperationLabel } from '../state/selectors'; +import { + selectActiveOperations, + selectAppNotifications, + selectUnreadAppNotifications, +} from '../state/selectors'; export interface RootLayoutProps { /** Runtime instance injected into the data-router route tree. */ @@ -31,13 +35,14 @@ const RootLayoutContent = () => { const navigation = useNavigation(); const fetchers = useFetchers(); const { connection } = useAppSession(); - const activeOperationLabel = useAppSelector(selectLatestActiveOperationLabel); + const activeOperations = useAppSelector(selectActiveOperations); + const appNotifications = useAppSelector(selectAppNotifications); + const unreadAppNotifications = useAppSelector(selectUnreadAppNotifications); const connectDialogOpen = connectOpen && connection.status !== 'connected'; const pending = derivePendingState({ navigation, fetchers, connectionStatus: connection.status, - activeOperationLabel, }); return ( @@ -50,6 +55,9 @@ const RootLayoutContent = () => { > setDrawerOpen(true)} onConnectClick={() => setConnectOpen(true)} /> diff --git a/src/app/TopBar.tsx b/src/app/TopBar.tsx index 6958ab95..2cf13232 100644 --- a/src/app/TopBar.tsx +++ b/src/app/TopBar.tsx @@ -1,6 +1,29 @@ -import { AppBar, Button, IconButton, Toolbar, Typography } from '@mui/material'; +import { useEffect, useMemo, useState, type MouseEvent } from 'react'; +import { + AppBar, + Badge, + Box, + Button, + CircularProgress, + IconButton, + List, + ListItemButton, + ListItemText, + Popover, + Toolbar, + Tooltip, + Typography, +} from '@mui/material'; import CircleIcon from '@mui/icons-material/Circle'; import MenuIcon from '@mui/icons-material/Menu'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import { Link as RouterLink } from 'react-router-dom'; +import type { AppNotificationRecord } from '../state/appNotifications.slice'; +import type { OperationRecord } from '../state/operations.slice'; +import { allAppNotificationsRead } from '../state/appNotifications.slice'; +import { useAppDispatch } from '../state/hooks'; + +const APP_NOTIFICATION_READ_DELAY_MS = 1250; /** * Props for the fixed app bar. @@ -8,6 +31,12 @@ import MenuIcon from '@mui/icons-material/Menu'; export interface TopBarProps { /** True when the shared app runtime has a connected Signify client. */ isConnected: boolean; + /** Currently running background operations. */ + activeOperations: readonly OperationRecord[]; + /** Recent app notifications for the bell popover. */ + recentNotifications: readonly AppNotificationRecord[]; + /** Number of unread app notifications. */ + unreadNotificationCount: number; /** Open the route navigation drawer. */ onMenuClick: () => void; /** Open the KERIA connection dialog. */ @@ -23,60 +52,233 @@ export interface TopBarProps { */ export const TopBar = ({ isConnected, + activeOperations, + recentNotifications, + unreadNotificationCount, onMenuClick, onConnectClick, -}: TopBarProps) => ( - - - - - - { + const [operationsAnchor, setOperationsAnchor] = + useState(null); + const [notificationsAnchor, setNotificationsAnchor] = + useState(null); + const dispatch = useAppDispatch(); + const operationsOpen = operationsAnchor !== null; + const notificationsOpen = notificationsAnchor !== null; + const visibleNotifications = useMemo( + () => recentNotifications.slice(0, 5), + [recentNotifications] + ); + + useEffect(() => { + if (!notificationsOpen || unreadNotificationCount === 0) { + return undefined; + } + + const timeout = globalThis.setTimeout(() => { + dispatch(allAppNotificationsRead()); + }, APP_NOTIFICATION_READ_DELAY_MS); + + return () => { + globalThis.clearTimeout(timeout); + }; + }, [dispatch, notificationsOpen, unreadNotificationCount]); + + const openOperations = (event: MouseEvent) => { + setOperationsAnchor(event.currentTarget); + }; + + const openNotifications = (event: MouseEvent) => { + setNotificationsAnchor(event.currentTarget); + }; + + return ( + + - Signify Client - - - - -); + + + + {activeOperations.length > 0 ? ( + + + + + + + + + + + + + setOperationsAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + {activeOperations.length === 0 ? ( + + ) : ( + activeOperations.map((operation) => ( + setOperationsAnchor(null)} + > + + + )) + )} + setOperationsAnchor(null)} + > + + + + + setNotificationsAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + {recentNotifications.length === 0 ? ( + + ) : ( + visibleNotifications.map((notification) => ( + setNotificationsAnchor(null)} + sx={{ + bgcolor: + notification.status === 'unread' + ? 'common.white' + : 'action.hover', + color: 'text.primary', + }} + > + + + )) + )} + setNotificationsAnchor(null)} + > + + + + + + ); +}; diff --git a/src/app/pendingState.ts b/src/app/pendingState.ts index 6e4afd1e..6a7d33ce 100644 --- a/src/app/pendingState.ts +++ b/src/app/pendingState.ts @@ -6,7 +6,7 @@ */ /** Source category currently driving the app-wide loading overlay. */ -export type PendingSource = 'navigation' | 'fetcher' | 'connection' | 'runtime'; +export type PendingSource = 'navigation' | 'fetcher' | 'connection'; /** Derived loading overlay state consumed by the shell. */ export interface AppPendingState { @@ -40,7 +40,6 @@ export interface DerivePendingStateInput { navigation: PendingNavigation; fetchers: readonly PendingFetcher[]; connectionStatus: 'idle' | 'connecting' | 'connected' | 'error'; - activeOperationLabel?: string | null; } const idlePendingState: AppPendingState = { @@ -151,7 +150,6 @@ export const derivePendingState = ({ navigation, fetchers, connectionStatus, - activeOperationLabel = null, }: DerivePendingStateInput): AppPendingState => { if (connectionStatus === 'connecting') { return { @@ -195,13 +193,5 @@ export const derivePendingState = ({ }; } - if (activeOperationLabel !== null) { - return { - active: true, - label: activeOperationLabel, - source: 'runtime', - }; - } - return idlePendingState; }; diff --git a/src/app/routeData.ts b/src/app/routeData.ts index a77d19da..29af6462 100644 --- a/src/app/routeData.ts +++ b/src/app/routeData.ts @@ -10,6 +10,7 @@ import type { SignifyClientConfig, SignifyStateSummary, } from '../signify/client'; +import type { BackgroundWorkflowStartResult } from './runtime'; /** * Canonical route used for startup redirects, unknown paths, and successful @@ -71,13 +72,15 @@ export type IdentifierActionData = intent: 'create' | 'rotate'; ok: true; message: string; - requestId?: string; + requestId: string; + operationRoute: string; } | { intent: 'create' | 'rotate' | 'unsupported'; ok: false; message: string; requestId?: string; + operationRoute?: string; }; /** @@ -121,6 +124,16 @@ export interface RouteDataRuntime { aid: string, options?: { signal?: AbortSignal; requestId?: string } ): Promise; + /** Start identifier creation in the background. */ + startCreateIdentifier( + draft: IdentifierCreateDraft, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; + /** Start identifier rotation in the background. */ + startRotateIdentifier( + aid: string, + options?: { requestId?: string } + ): BackgroundWorkflowStartResult; } /** @@ -317,15 +330,25 @@ export const identifiersAction = async ( } try { - await runtime.createIdentifier(draft, { - signal: request.signal, - requestId, + const started = runtime.startCreateIdentifier(draft, { + 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: `Created identifier ${draft.name}`, - requestId, + message: `Creating identifier ${draft.name}`, + requestId: started.requestId, + operationRoute: started.operationRoute, }; } catch (error) { return { @@ -339,20 +362,34 @@ export const identifiersAction = async ( if (intent === 'rotate') { const aid = formString(formData, 'aid'); + const requestId = formString(formData, 'requestId'); try { - await runtime.rotateIdentifier(aid, { - signal: request.signal, + const started = runtime.startRotateIdentifier(aid, { + 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: `Rotated identifier ${aid}`, + message: `Rotating identifier ${aid}`, + requestId: started.requestId, + operationRoute: started.operationRoute, }; } catch (error) { return { intent, ok: false, message: toRouteError(error).message, + requestId, }; } } diff --git a/src/app/router.tsx b/src/app/router.tsx index 56975c54..b410437a 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,6 +6,9 @@ import { import { ClientView } from '../features/client/ClientView'; import { CredentialsView } from '../features/credentials/CredentialsView'; import { IdentifiersView } from '../features/identifiers/IdentifiersView'; +import { AppNotificationsView } from '../features/notifications/AppNotificationsView'; +import { OperationDetailView } from '../features/operations/OperationDetailView'; +import { OperationsView } from '../features/operations/OperationsView'; import type { AppRuntime } from './runtime'; import { DEFAULT_APP_PATH, @@ -21,7 +24,12 @@ import { RouteErrorBoundary } from './RouteErrorBoundary'; /** * Stable IDs for feature routes that appear in the app shell. */ -export type AppRouteId = 'identifiers' | 'credentials' | 'client'; +export type AppRouteId = + | 'identifiers' + | 'credentials' + | 'client' + | 'operations' + | 'appNotifications'; /** * Gate policy declared by a route handle. @@ -29,7 +37,7 @@ export type AppRouteId = 'identifiers' | 'credentials' | 'client'; * `client` means the route needs a connected Signify client. `state` means the * route also needs the latest normalized client state snapshot. */ -export type AppRouteGate = 'client' | 'state'; +export type AppRouteGate = 'none' | 'client' | 'state'; /** * Metadata stored in React Router's native `handle` field for app routes. @@ -110,6 +118,28 @@ const APP_FEATURE_ROUTES: readonly AppFeatureRouteDescriptor[] = [ testId: 'nav-client', }, }, + { + id: 'operations', + path: 'operations', + handle: { + routeId: 'operations', + label: 'Operations', + gate: 'none', + nav: true, + testId: 'nav-operations', + }, + }, + { + id: 'appNotifications', + path: 'notifications', + handle: { + routeId: 'appNotifications', + label: 'Notifications', + gate: 'none', + nav: true, + testId: 'nav-notifications', + }, + }, ] as const; /** @@ -171,6 +201,32 @@ export const createAppRoutes = (runtime: AppRuntime): RouteObject[] => [ element: , errorElement: , }, + { + id: 'operations', + path: 'operations', + handle: APP_FEATURE_ROUTES[3].handle, + element: , + errorElement: ( + + ), + }, + { + id: 'operationDetail', + path: 'operations/:requestId', + element: , + errorElement: ( + + ), + }, + { + id: 'appNotifications', + path: 'notifications', + handle: APP_FEATURE_ROUTES[4].handle, + element: , + errorElement: ( + + ), + }, { path: '*', loader: () => redirect(DEFAULT_APP_PATH), diff --git a/src/app/runtime.ts b/src/app/runtime.ts index e462d3c7..4394a255 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -14,13 +14,28 @@ import { type SignifyClientConfig, type SignifyStateSummary, } from '../signify/client'; +import { + appNotificationRecorded, + type AppNotificationLink, + type AppNotificationRecord, + type AppNotificationSeverity, +} from '../state/appNotifications.slice'; import { cancelRunningOperations, + type OperationKind, + type OperationRouteLink, operationCanceled, operationFailed, + operationResultLinked, operationStarted, operationSucceeded, } from '../state/operations.slice'; +import { + flushPersistedAppState, + installAppStatePersistence, + rehydratePersistedAppState, + type AppStateStorage, +} from '../state/persistence'; import { sessionDisconnected } from '../state/session.slice'; import { appStore, type AppStore } from '../state/store'; import { @@ -87,6 +102,8 @@ export interface AppRuntimeOptions { config?: AppConfig; /** Optional logger called during KERIA operation waits. */ logger?: OperationLogger; + /** Optional persistence storage override; `null` disables persistence. */ + storage?: AppStateStorage | null; } /** @@ -100,13 +117,45 @@ export interface WorkflowRunOptions { /** User-facing pending label stored in the operations slice. */ label?: string; /** Machine-readable operation category for diagnostics. */ - kind?: string; + kind?: OperationKind; /** Effection scope lifetime for the launched workflow. */ scope?: RuntimeScopeKind; /** Whether to write operation lifecycle records into Redux. */ track?: boolean; } +export interface OperationNotificationTemplate { + title: string; + message: string; + severity?: AppNotificationSeverity; +} + +export interface BackgroundWorkflowRunOptions { + requestId?: string; + label: string; + title?: string; + description?: string | null; + kind: OperationKind; + scope?: RuntimeScopeKind; + resourceKeys?: readonly string[]; + resultRoute?: OperationRouteLink | null; + successNotification?: OperationNotificationTemplate; + failureNotification?: OperationNotificationTemplate; +} + +export type BackgroundWorkflowStartResult = + | { + status: 'accepted'; + requestId: string; + operationRoute: string; + } + | { + status: 'conflict'; + requestId: string; + operationRoute: string; + message: string; + }; + /** * Initial disconnected runtime state. * @@ -149,6 +198,11 @@ const abortError = (signal?: AbortSignal): Error => { return error; }; +const operationRoute = (requestId: string): string => `/operations/${requestId}`; + +const notificationId = (requestId: string): string => + `notification-${requestId}-${Date.now()}`; + /** * Data-router-safe Signify session and command boundary. * @@ -166,6 +220,12 @@ export class AppRuntime { private readonly activeTasks = new Map>(); + private readonly storage: AppStateStorage | null | undefined; + + private currentControllerAid: string | null = null; + + private readonly uninstallPersistence: () => void; + /** * Current runtime snapshot exposed to React and route functions. */ @@ -189,6 +249,14 @@ export class AppRuntime { store: this.store, logger, }); + + this.storage = + options.storage === undefined ? undefined : options.storage; + this.uninstallPersistence = installAppStatePersistence( + this.store, + () => this.currentControllerAid, + this.storage + ); } /** @@ -261,6 +329,7 @@ export class AppRuntime { } ); await this.scopes.startSession(); + this.setPersistenceController(connected.state.controllerPre); this.setConnection({ status: 'connected', client: connected.client, @@ -291,9 +360,11 @@ export class AppRuntime { reason: 'Session disconnected.', }) ); + this.flushPersistence(); void this.scopes.haltSession(); this.store.dispatch(sessionDisconnected()); this.setConnection(idleConnection); + this.currentControllerAid = null; }; /** @@ -378,6 +449,107 @@ export class AppRuntime { }); }; + startCreateIdentifier = ( + draft: IdentifierCreateDraft, + options: Pick = {} + ): BackgroundWorkflowStartResult => { + const name = draft.name.trim(); + return this.startBackgroundWorkflow(() => createIdentifierOp(draft), { + requestId: options.requestId, + label: `Creating identifier ${name}`, + title: `Create identifier ${name}`, + description: 'Creates a managed identifier and waits for KERIA completion.', + kind: 'createIdentifier', + resourceKeys: [`identifier:name:${name}`], + resultRoute: { label: 'View identifiers', path: '/identifiers' }, + successNotification: { + title: `Identifier ${name} created`, + message: 'The identifier operation completed successfully.', + severity: 'success', + }, + failureNotification: { + title: `Identifier ${name} failed`, + message: 'The identifier operation failed.', + severity: 'error', + }, + }); + }; + + startRotateIdentifier = ( + aid: string, + options: Pick = {} + ): BackgroundWorkflowStartResult => + this.startBackgroundWorkflow(() => rotateIdentifierOp(aid), { + requestId: options.requestId, + label: `Rotating identifier ${aid}`, + title: `Rotate identifier ${aid}`, + description: 'Rotates a managed identifier and waits for KERIA completion.', + kind: 'rotateIdentifier', + resourceKeys: [`identifier:aid:${aid}`], + resultRoute: { label: 'View identifiers', path: '/identifiers' }, + successNotification: { + title: 'Identifier rotation complete', + message: `The rotation for ${aid} completed successfully.`, + severity: 'success', + }, + failureNotification: { + title: 'Identifier rotation failed', + message: `The rotation for ${aid} failed.`, + severity: 'error', + }, + }); + + /** + * Start a non-blocking workflow and return accepted/conflict metadata. + * + * This is the top-level handoff point for background KERIA work. It owns + * conflict checks, operation history, task retention, and completion + * notifications so route actions can return immediately without losing + * lifecycle facts. + */ + startBackgroundWorkflow = ( + operation: () => EffectionOperation, + options: BackgroundWorkflowRunOptions + ): BackgroundWorkflowStartResult => { + const resourceKeys = [...(options.resourceKeys ?? [])]; + const conflict = this.findResourceConflict(resourceKeys); + if (conflict !== null) { + return { + status: 'conflict', + requestId: conflict.requestId, + operationRoute: conflict.operationRoute, + message: `Already working on ${conflict.title}.`, + }; + } + + const requestId = options.requestId ?? createRequestId(); + const route = operationRoute(requestId); + this.store.dispatch( + operationStarted({ + requestId, + label: options.label, + title: options.title ?? options.label, + description: options.description ?? null, + kind: options.kind, + phase: 'running', + resourceKeys, + operationRoute: route, + resultRoute: options.resultRoute ?? null, + }) + ); + + const task = this.scopes.run(operation, options.scope ?? 'session'); + this.activeTasks.set(requestId, task); + + void this.watchBackgroundTask(task, requestId, options); + + return { + status: 'accepted', + requestId, + operationRoute: route, + }; + }; + /** * Bridge React Router's Promise-facing APIs into Effection operations. * @@ -460,6 +632,137 @@ export class AppRuntime { } }; + private watchBackgroundTask = async ( + task: Task, + requestId: string, + options: BackgroundWorkflowRunOptions + ): Promise => { + try { + await task; + this.store.dispatch(operationSucceeded({ requestId })); + this.recordCompletionNotification(requestId, options, 'success'); + } catch (error) { + if (isHaltedOrAborted(error)) { + this.store.dispatch( + operationCanceled({ + requestId, + reason: 'Operation canceled.', + }) + ); + } else { + this.store.dispatch( + operationFailed({ + requestId, + error: toErrorText(error), + }) + ); + this.recordCompletionNotification( + requestId, + options, + 'error', + toErrorText(error) + ); + } + } finally { + this.activeTasks.delete(requestId); + } + }; + + /** + * Create the user-facing app notification for a completed background task. + * + * Notifications are derived from runtime operation metadata so every + * notification has a stable operation link and optional result link. + */ + private recordCompletionNotification = ( + requestId: string, + options: BackgroundWorkflowRunOptions, + outcome: 'success' | 'error', + error?: string + ): void => { + const template = + outcome === 'success' + ? options.successNotification + : options.failureNotification; + if (template === undefined) { + return; + } + + const id = notificationId(requestId); + const links: AppNotificationLink[] = [ + { + rel: 'operation', + label: 'View operation', + path: operationRoute(requestId), + }, + ]; + if (options.resultRoute !== null && options.resultRoute !== undefined) { + links.push({ + rel: 'result', + label: options.resultRoute.label, + path: options.resultRoute.path, + }); + } + + const notification: AppNotificationRecord = { + id, + severity: + template.severity ?? (outcome === 'success' ? 'success' : 'error'), + status: 'unread', + title: template.title, + message: + error === undefined + ? template.message + : `${template.message} ${error}`, + createdAt: new Date().toISOString(), + readAt: null, + operationId: requestId, + links, + }; + + this.store.dispatch(appNotificationRecorded(notification)); + this.store.dispatch( + operationResultLinked({ + requestId, + resultRoute: options.resultRoute ?? null, + notificationId: id, + }) + ); + }; + + /** + * Find a running operation that owns any requested resource key. + */ + private findResourceConflict = ( + resourceKeys: readonly string[] + ): { + requestId: string; + title: string; + operationRoute: string; + } | null => { + if (resourceKeys.length === 0) { + return null; + } + + const requested = new Set(resourceKeys); + const state = this.store.getState(); + for (const requestId of state.operations.order) { + const record = state.operations.byId[requestId]; + if ( + record?.status === 'running' && + record.resourceKeys.some((key) => requested.has(key)) + ) { + return { + requestId: record.requestId, + title: record.title, + operationRoute: record.operationRoute, + }; + } + } + + return null; + }; + /** * Halt app-owned Effection work during React unmount, HMR, or page teardown. */ @@ -469,6 +772,7 @@ export class AppRuntime { reason: 'App runtime destroyed.', }) ); + this.flushPersistence(); for (const task of this.activeTasks.values()) { await task.halt(); @@ -476,8 +780,10 @@ export class AppRuntime { this.activeTasks.clear(); await this.scopes.destroy(); + this.uninstallPersistence(); this.store.dispatch(sessionDisconnected()); this.setConnection(idleConnection); + this.currentControllerAid = null; }; /** @@ -508,6 +814,27 @@ export class AppRuntime { listener(); } }; + + private setPersistenceController = (controllerAid: string | null): void => { + if (controllerAid === this.currentControllerAid) { + return; + } + + // Persist under the old controller before loading a different bucket. + this.flushPersistence(); + this.currentControllerAid = controllerAid; + if (controllerAid !== null) { + rehydratePersistedAppState(this.store, controllerAid, this.storage); + } + }; + + private flushPersistence = (): void => { + flushPersistedAppState( + this.store, + this.currentControllerAid, + this.storage + ); + }; } /** diff --git a/src/features/identifiers/IdentifierDetailsModal.tsx b/src/features/identifiers/IdentifierDetailsModal.tsx index f5814619..4a9b859c 100644 --- a/src/features/identifiers/IdentifierDetailsModal.tsx +++ b/src/features/identifiers/IdentifierDetailsModal.tsx @@ -1,6 +1,11 @@ +import type { ReactNode } from 'react'; import { + Accordion, + AccordionDetails, + AccordionSummary, Box, Button, + Chip, Dialog, DialogActions, DialogContent, @@ -8,7 +13,20 @@ import { Stack, Typography, } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import RotateRightIcon from '@mui/icons-material/RotateRight'; import type { IdentifierSummary } from './identifierTypes'; +import { + formatIdentifierMetadata, + identifierCurrentKey, + identifierCurrentKeys, + identifierIdentifierIndex, + identifierJson, + identifierKeyIndex, + identifierTier, + identifierType, + identifierUnavailableValue, +} from './identifierHelpers'; /** * Props for the identifier detail modal. @@ -21,44 +39,163 @@ export interface IdentifierDetailsModalProps { onRotate: (name: string) => void; } -/** - * Best-effort display type for the current Signify identifier payload. - * - * The legacy UI used the third object key to find the algorithm-specific - * details. Preserve that behavior here until the identifier model is made - * explicit. - */ -const identifierType = (identifier: IdentifierSummary): string => - 'salty' in identifier - ? 'salty' - : 'randy' in identifier - ? 'randy' - : 'group' in identifier - ? 'group' - : 'extern' in identifier - ? 'extern' - : ''; - -const identifierDetails = (identifier: IdentifierSummary): unknown => { - if ('salty' in identifier) { - return identifier.salty; +interface DetailFieldProps { + label: string; + value: string; + mono?: boolean; + accessory?: ReactNode; + footprint?: 'compact' | 'medium' | 'wide'; + tone?: 'neutral' | 'identity' | 'key' | 'metric'; +} + +const detailGridColumn = (footprint: DetailFieldProps['footprint']) => ({ + xs: '1 / -1', + sm: + footprint === 'wide' + ? '1 / -1' + : footprint === 'compact' + ? 'span 2' + : 'span 3', +}); + +const detailTone = (tone: DetailFieldProps['tone']) => { + if (tone === 'identity') { + return { bgcolor: 'background.paper', borderColor: 'primary.light' }; } - if ('randy' in identifier) { - return identifier.randy; + if (tone === 'key') { + return { bgcolor: 'background.paper', borderColor: 'success.light' }; } - if ('group' in identifier) { - return identifier.group; + if (tone === 'metric') { + return { bgcolor: 'action.hover', borderColor: 'divider' }; } - if ('extern' in identifier) { - return identifier.extern; + return { bgcolor: 'background.paper', borderColor: 'divider' }; +}; + +const DetailField = ({ + label, + value, + mono = false, + accessory, + footprint = 'medium', + tone = 'neutral', +}: DetailFieldProps) => ( + + + {label} + + + + {value} + + {accessory} + + +); + +const jsonTokenPattern = + /("(?:\\.|[^"\\])*"(?=\s*:)|"(?:\\.|[^"\\])*"|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; + +const renderJsonLine = (line: string, lineIndex: number): ReactNode[] => { + const nodes: ReactNode[] = []; + let lastIndex = 0; + + for (const match of line.matchAll(jsonTokenPattern)) { + const token = match[0]; + const index = match.index ?? 0; + + if (index > lastIndex) { + nodes.push(line.slice(lastIndex, index)); + } + + const remainder = line.slice(index + token.length).trimStart(); + const className = token.startsWith('"') + ? remainder.startsWith(':') + ? 'json-key' + : 'json-string' + : token === 'true' || token === 'false' + ? 'json-boolean' + : token === 'null' + ? 'json-null' + : 'json-number'; + + nodes.push( + + {token} + + ); + lastIndex = index + token.length; } - return undefined; + if (lastIndex < line.length) { + nodes.push(line.slice(lastIndex)); + } + + return nodes; }; +const JsonCodeBlock = ({ value }: { value: string }) => ( + + {value.split('\n').map((line, index, lines) => ( + + {renderJsonLine(line, index)} + {index < lines.length - 1 ? '\n' : null} + + ))} + +); + /** * Identifier details and rotate action. * @@ -72,7 +209,12 @@ export const IdentifierDetailsModal = ({ onClose, onRotate, }: IdentifierDetailsModalProps) => { - const type = identifier ? identifierType(identifier) : ''; + const currentKeys = identifier === null ? [] : identifierCurrentKeys(identifier); + const currentKey = + identifier === null + ? identifierUnavailableValue + : (identifierCurrentKey(identifier) ?? identifierUnavailableValue); + const additionalKeyCount = Math.max(currentKeys.length - 1, 0); return ( - - Name: {identifier?.name} - Prefix: {identifier?.prefix} - Type: {type} + - {JSON.stringify( - identifier === null - ? undefined - : identifierDetails(identifier), - null, - 2 - )} + + + + 0 ? ( + + ) : null + } + /> + + + + {identifier !== null && ( + + }> + Advanced JSON + + + + + + )} + + ); + } + + return ( + + + + {operation.title} + + + + {operation.description && ( + + {operation.description} + + )} + + + + + + + + + + + 0 + ? operation.resourceKeys.join(', ') + : null + } + /> + + + + {operation.resultRoute && ( + + )} + {operation.notificationId && ( + + View notification + + )} + + + ); +}; diff --git a/src/features/operations/OperationsView.tsx b/src/features/operations/OperationsView.tsx new file mode 100644 index 00000000..d972a646 --- /dev/null +++ b/src/features/operations/OperationsView.tsx @@ -0,0 +1,76 @@ +import { + Box, + Chip, + List, + ListItemButton, + ListItemText, + Stack, + Typography, +} from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import { useAppSelector } from '../../state/hooks'; +import { selectOperationRecords } from '../../state/selectors'; + +export const OperationsView = () => { + const operations = [...useAppSelector(selectOperationRecords)].reverse(); + + return ( + + + Operations + + {operations.length === 0 ? ( + + No operations have run in this browser session. + + ) : ( + + {operations.map((operation) => ( + + + + {operation.title} + + + + } + secondary={`${operation.kind} | ${operation.phase}`} + /> + + ))} + + )} + + ); +}; diff --git a/src/index.css b/src/index.css index 0b869398..9bda864a 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,9 @@ +@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600&display=swap'); + :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + --app-mono-font: 'Source Code Pro', 'Roboto Mono', 'SFMono-Regular', + Consolas, 'Liberation Mono', monospace; line-height: 1.5; font-weight: 400; diff --git a/src/services/identifiers.service.ts b/src/services/identifiers.service.ts index f86e4d44..ce23ae77 100644 --- a/src/services/identifiers.service.ts +++ b/src/services/identifiers.service.ts @@ -5,6 +5,7 @@ import { callPromise } from '../effects/promise'; import { identifierCreateDraftToArgs, identifiersFromResponse, + replaceIdentifierSummary, } from '../features/identifiers/identifierHelpers'; import type { IdentifierCreateDraft, @@ -25,6 +26,19 @@ export function* listIdentifiersService({ return identifiersFromResponse(response); } +/** + * Fetch one managed identifier by alias or prefix. + */ +export function* getIdentifierService({ + client, + aid, +}: { + client: SignifyClient; + aid: string; +}): EffectionOperation { + return yield* callPromise(() => client.identifiers().get(aid)); +} + /** * Create an identifier from a UI draft and return the refreshed identifier list. * @@ -81,5 +95,8 @@ export function* rotateIdentifierService({ logger, }); - return yield* listIdentifiersService({ client }); + const identifiers = yield* listIdentifiersService({ client }); + const refreshed = yield* getIdentifierService({ client, aid }); + + return replaceIdentifierSummary(identifiers, refreshed); } diff --git a/src/state/appNotifications.slice.ts b/src/state/appNotifications.slice.ts new file mode 100644 index 00000000..64e287ef --- /dev/null +++ b/src/state/appNotifications.slice.ts @@ -0,0 +1,156 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +/** + * Bounded retention for user-facing app notifications. + * + * These are broader than KERIA notifications: they describe app work such as + * background operation completion/failure and link the user back to app + * context. + */ +export const APP_NOTIFICATION_HISTORY_LIMIT = 100; + +/** User-facing notification severity for shell/list styling. */ +export type AppNotificationSeverity = 'info' | 'success' | 'warning' | 'error'; + +/** Read state is owned by the app notification UX, not by KERIA. */ +export type AppNotificationStatus = 'unread' | 'read'; + +/** Link relationships supported by app notification cards and popovers. */ +export type AppNotificationLinkRel = 'operation' | 'result'; + +/** Serializable app route link attached to a user notification. */ +export interface AppNotificationLink { + rel: AppNotificationLinkRel; + label: string; + path: string; +} + +/** + * User-facing notification record. + * + * Keep this serializable and app-scoped. Do not store raw KERIA notification + * payloads here; those belong in `notifications.slice.ts`. + */ +export interface AppNotificationRecord { + id: string; + severity: AppNotificationSeverity; + status: AppNotificationStatus; + title: string; + message: string; + createdAt: string; + readAt: string | null; + operationId: string | null; + links: AppNotificationLink[]; +} + +/** App notification state keyed by notification id in retention order. */ +export interface AppNotificationsState { + byId: Record; + ids: string[]; +} + +const initialState: AppNotificationsState = { + byId: {}, + ids: [], +}; + +const now = (): string => new Date().toISOString(); + +/** + * Drop oldest notifications after the history limit. + */ +const trimHistory = (state: AppNotificationsState): void => { + while (state.ids.length > APP_NOTIFICATION_HISTORY_LIMIT) { + const removableId = state.ids[0]; + state.ids = state.ids.slice(1); + delete state.byId[removableId]; + } +}; + +/** + * Upsert by id so persistence rehydration and runtime completion can share the + * same bounded insertion behavior. + */ +const upsertNotification = ( + state: AppNotificationsState, + notification: AppNotificationRecord +): void => { + state.byId[notification.id] = notification; + if (!state.ids.includes(notification.id)) { + state.ids.push(notification.id); + } + trimHistory(state); +}; + +/** + * Redux slice for app-level user notifications. + */ +export const appNotificationsSlice = createSlice({ + name: 'appNotifications', + initialState, + reducers: { + appNotificationRecorded( + state, + { payload }: PayloadAction + ) { + upsertNotification(state, payload); + }, + appNotificationRead: { + reducer( + state, + { payload }: PayloadAction<{ id: string; readAt: string }> + ) { + const notification = state.byId[payload.id]; + if (notification !== undefined) { + notification.status = 'read'; + notification.readAt = payload.readAt; + } + }, + prepare(payload: { id: string; readAt?: string }) { + return { + payload: { + id: payload.id, + readAt: payload.readAt ?? now(), + }, + }; + }, + }, + allAppNotificationsRead: { + reducer(state, { payload }: PayloadAction<{ readAt: string }>) { + for (const id of state.ids) { + const notification = state.byId[id]; + if (notification?.status === 'unread') { + notification.status = 'read'; + notification.readAt = payload.readAt; + } + } + }, + prepare(payload: { readAt?: string } = {}) { + return { + payload: { + readAt: payload.readAt ?? now(), + }, + }; + }, + }, + appNotificationsRehydrated( + state, + { payload }: PayloadAction<{ records: AppNotificationRecord[] }> + ) { + state.byId = {}; + state.ids = []; + for (const record of payload.records) { + upsertNotification(state, record); + } + }, + }, +}); + +export const { + appNotificationRecorded, + appNotificationRead, + allAppNotificationsRead, + appNotificationsRehydrated, +} = appNotificationsSlice.actions; + +export const appNotificationsReducer = appNotificationsSlice.reducer; diff --git a/src/state/operations.slice.ts b/src/state/operations.slice.ts index 98431f50..98a6415d 100644 --- a/src/state/operations.slice.ts +++ b/src/state/operations.slice.ts @@ -6,7 +6,36 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; export const OPERATION_HISTORY_LIMIT = 100; /** Public lifecycle for route/workflow operations tracked in Redux. */ -export type OperationStatus = 'running' | 'success' | 'error' | 'canceled'; +export type OperationStatus = + | 'running' + | 'success' + | 'error' + | 'canceled' + | 'interrupted'; + +/** Typed operation categories exposed by the app operations UX. */ +export type OperationKind = + | 'connect' + | 'generatePasscode' + | 'refreshState' + | 'listIdentifiers' + | 'createIdentifier' + | 'rotateIdentifier' + | 'resolveContact' + | 'resolveSchema' + | 'createRegistry' + | 'issueCredential' + | 'grantCredential' + | 'admitCredential' + | 'presentCredential' + | 'pollNotifications' + | 'workflow'; + +/** Serializable link from an operation to related app context. */ +export interface OperationRouteLink { + label: string; + path: string; +} /** * Serializable operation record for UI pending state and debugging. @@ -14,8 +43,16 @@ export type OperationStatus = 'running' | 'success' | 'error' | 'canceled'; export interface OperationRecord { requestId: string; label: string; - kind: string; + title: string; + description: string | null; + kind: OperationKind; status: OperationStatus; + phase: string; + resourceKeys: string[]; + operationRoute: string; + resultRoute: OperationRouteLink | null; + notificationId: string | null; + keriaOperationName: string | null; startedAt: string; finishedAt: string | null; error: string | null; @@ -40,6 +77,8 @@ const initialState: OperationsState = { */ const now = (): string => new Date().toISOString(); +const operationRoute = (requestId: string): string => `/operations/${requestId}`; + /** * Trim completed/canceled/failed operation history without dropping active work. */ @@ -74,12 +113,28 @@ const closeOperation = ( } record.status = status; + record.phase = status; record.finishedAt = finishedAt; record.error = error; record.canceledReason = canceledReason; trimHistory(state); }; +/** + * Upsert a record while preserving display order. + */ +const upsertOperation = ( + state: OperationsState, + record: OperationRecord +): void => { + state.byId[record.requestId] = record; + state.order = state.order.filter( + (requestId) => requestId !== record.requestId + ); + state.order.push(record.requestId); + trimHistory(state); +}; + /** * Redux slice for runtime workflow lifecycle tracking. */ @@ -95,40 +150,106 @@ export const operationsSlice = createSlice({ }: PayloadAction<{ requestId: string; label: string; - kind: string; + title: string; + description: string | null; + kind: OperationKind; + phase: string; + resourceKeys: string[]; + operationRoute: string; + resultRoute: OperationRouteLink | null; + notificationId: string | null; + keriaOperationName: string | null; startedAt: string; }> ) { - state.byId[payload.requestId] = { + upsertOperation(state, { requestId: payload.requestId, label: payload.label, + title: payload.title, + description: payload.description, kind: payload.kind, status: 'running', + phase: payload.phase, + resourceKeys: payload.resourceKeys, + operationRoute: payload.operationRoute, + resultRoute: payload.resultRoute, + notificationId: payload.notificationId, + keriaOperationName: payload.keriaOperationName, startedAt: payload.startedAt, finishedAt: null, error: null, canceledReason: null, - }; - state.order = state.order.filter( - (requestId) => requestId !== payload.requestId - ); - state.order.push(payload.requestId); - trimHistory(state); + }); }, prepare(payload: { requestId: string; label: string; - kind: string; + kind?: OperationKind; + title?: string; + description?: string | null; + phase?: string; + resourceKeys?: readonly string[]; + operationRoute?: string; + resultRoute?: OperationRouteLink | null; + notificationId?: string | null; + keriaOperationName?: string | null; startedAt?: string; }) { return { payload: { ...payload, + title: payload.title ?? payload.label, + description: payload.description ?? null, + kind: payload.kind ?? 'workflow', + phase: payload.phase ?? 'running', + resourceKeys: [...(payload.resourceKeys ?? [])], + operationRoute: + payload.operationRoute ?? + operationRoute(payload.requestId), + resultRoute: payload.resultRoute ?? null, + notificationId: payload.notificationId ?? null, + keriaOperationName: payload.keriaOperationName ?? null, startedAt: payload.startedAt ?? now(), }, }; }, }, + operationPhaseChanged( + state, + { + payload, + }: PayloadAction<{ + requestId: string; + phase: string; + keriaOperationName?: string | null; + }> + ) { + const record = state.byId[payload.requestId]; + if (record !== undefined) { + record.phase = payload.phase; + if (payload.keriaOperationName !== undefined) { + record.keriaOperationName = payload.keriaOperationName; + } + } + }, + operationResultLinked( + state, + { + payload, + }: PayloadAction<{ + requestId: string; + resultRoute: OperationRouteLink | null; + notificationId?: string | null; + }> + ) { + const record = state.byId[payload.requestId]; + if (record !== undefined) { + record.resultRoute = payload.resultRoute; + if (payload.notificationId !== undefined) { + record.notificationId = payload.notificationId; + } + } + }, operationSucceeded: { reducer( state, @@ -231,6 +352,7 @@ export const operationsSlice = createSlice({ const record = state.byId[requestId]; if (record?.status === 'running') { record.status = 'canceled'; + record.phase = 'canceled'; record.finishedAt = payload.finishedAt; record.canceledReason = payload.reason; } @@ -246,6 +368,32 @@ export const operationsSlice = createSlice({ }; }, }, + operationsRehydrated( + state, + { + payload, + }: PayloadAction<{ + records: OperationRecord[]; + interruptedAt: string; + }> + ) { + state.byId = {}; + state.order = []; + for (const record of payload.records) { + const normalized = + record.status === 'running' + ? { + ...record, + status: 'interrupted' as const, + phase: 'interrupted', + finishedAt: payload.interruptedAt, + canceledReason: + 'Browser refresh stopped the local operation watcher.', + } + : record; + upsertOperation(state, normalized); + } + }, }, }); @@ -255,7 +403,10 @@ export const { operationSucceeded, operationFailed, operationCanceled, + operationPhaseChanged, + operationResultLinked, cancelRunningOperations, + operationsRehydrated, } = operationsSlice.actions; /** Reducer mounted at `state.operations`. */ diff --git a/src/state/persistence.ts b/src/state/persistence.ts new file mode 100644 index 00000000..135dc948 --- /dev/null +++ b/src/state/persistence.ts @@ -0,0 +1,232 @@ +import { appNotificationsRehydrated } from './appNotifications.slice'; +import { operationsRehydrated } from './operations.slice'; +import type { AppNotificationRecord } from './appNotifications.slice'; +import type { OperationRecord } from './operations.slice'; +import type { AppStore, RootState } from './store'; + +const PERSISTENCE_VERSION = 1; +const PERSISTENCE_KEY_PREFIX = 'signify-react-ts:app-state:v1'; + +/** + * Minimal storage contract so tests can inject memory storage and production + * can use browser localStorage. + */ +export interface AppStateStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +/** + * Supplies the currently connected controller AID for controller-scoped saves. + */ +export type ControllerAidProvider = () => string | null; + +/** + * Versioned, serializable state persisted per controller AID. + */ +export interface PersistedAppState { + version: typeof PERSISTENCE_VERSION; + operations: OperationRecord[]; + appNotifications: AppNotificationRecord[]; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const hasString = (record: Record, key: string): boolean => + typeof record[key] === 'string'; + +const isOperationRecord = (value: unknown): value is OperationRecord => { + if (!isRecord(value)) { + return false; + } + + return ( + hasString(value, 'requestId') && + hasString(value, 'label') && + hasString(value, 'title') && + hasString(value, 'kind') && + hasString(value, 'status') && + hasString(value, 'phase') && + hasString(value, 'operationRoute') && + hasString(value, 'startedAt') && + Array.isArray(value.resourceKeys) + ); +}; + +const isAppNotificationRecord = ( + value: unknown +): value is AppNotificationRecord => { + if (!isRecord(value)) { + return false; + } + + return ( + hasString(value, 'id') && + hasString(value, 'severity') && + hasString(value, 'status') && + hasString(value, 'title') && + hasString(value, 'message') && + hasString(value, 'createdAt') && + Array.isArray(value.links) + ); +}; + +const browserStorage = (): AppStateStorage | null => { + try { + const storage = globalThis.localStorage; + return storage !== undefined && + typeof storage.getItem === 'function' && + typeof storage.setItem === 'function' + ? storage + : null; + } catch { + return null; + } +}; + +/** + * Build the controller-scoped storage key. + * + * The controller AID is part of the key because one browser can authenticate + * multiple Signify controllers and their histories must not mix. + */ +export const persistedAppStateKey = (controllerAid: string): string => + `${PERSISTENCE_KEY_PREFIX}:${controllerAid}`; + +/** + * Load one controller's persisted app state, filtering malformed records. + */ +export const loadPersistedAppState = ( + controllerAid: string, + storage: AppStateStorage | null = browserStorage() +): PersistedAppState | null => { + if (storage === null) { + return null; + } + + const text = storage.getItem(persistedAppStateKey(controllerAid)); + if (text === null) { + return null; + } + + try { + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed) || parsed.version !== PERSISTENCE_VERSION) { + return null; + } + + const operations = Array.isArray(parsed.operations) + ? parsed.operations.filter(isOperationRecord) + : []; + const appNotifications = Array.isArray(parsed.appNotifications) + ? parsed.appNotifications.filter(isAppNotificationRecord) + : []; + + return { + version: PERSISTENCE_VERSION, + operations, + appNotifications, + }; + } catch { + return null; + } +}; + +/** + * Project Redux state into the bounded, serializable persistence shape. + */ +export const persistedAppStateFromRoot = ( + state: RootState +): PersistedAppState => ({ + version: PERSISTENCE_VERSION, + operations: state.operations.order + .map((requestId) => state.operations.byId[requestId]) + .filter((record): record is OperationRecord => record !== undefined), + appNotifications: state.appNotifications.ids + .map((id) => state.appNotifications.byId[id]) + .filter( + (record): record is AppNotificationRecord => record !== undefined + ), +}); + +/** + * Save the current operation and app-notification facts for one controller. + */ +export const savePersistedAppState = ( + state: RootState, + controllerAid: string, + storage: AppStateStorage | null = browserStorage() +): void => { + if (storage === null) { + return; + } + + storage.setItem( + persistedAppStateKey(controllerAid), + JSON.stringify(persistedAppStateFromRoot(state)) + ); +}; + +/** + * Rehydrate one controller's persisted state. + * + * Running operations become interrupted because the browser-side Effection + * watcher cannot survive refresh or tab close in this phase. + */ +export const rehydratePersistedAppState = ( + store: AppStore, + controllerAid: string, + storage: AppStateStorage | null = browserStorage() +): void => { + const persisted = loadPersistedAppState(controllerAid, storage); + const interruptedAt = new Date().toISOString(); + store.dispatch( + operationsRehydrated({ + records: persisted?.operations ?? [], + interruptedAt, + }) + ); + store.dispatch( + appNotificationsRehydrated({ + records: persisted?.appNotifications ?? [], + }) + ); +}; + +/** + * Subscribe to store writes and eagerly persist under the active controller. + */ +export const installAppStatePersistence = ( + store: AppStore, + controllerAid: ControllerAidProvider, + storage: AppStateStorage | null = browserStorage() +): (() => void) => { + if (storage === null) { + return () => undefined; + } + + return store.subscribe(() => { + const currentControllerAid = controllerAid(); + if (currentControllerAid !== null) { + savePersistedAppState( + store.getState(), + currentControllerAid, + storage + ); + } + }); +}; + +/** + * Flush the latest state before disconnect, runtime destroy, pagehide, or HMR. + */ +export const flushPersistedAppState = ( + store: AppStore, + controllerAid: string | null, + storage: AppStateStorage | null = browserStorage() +): void => { + if (controllerAid !== null) { + savePersistedAppState(store.getState(), controllerAid, storage); + } +}; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index e7f203e8..fc0a4b77 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -1,4 +1,6 @@ import type { RootState } from './store'; +import type { OperationRecord } from './operations.slice'; +import type { AppNotificationRecord } from './appNotifications.slice'; /** Select the serializable session connection summary. */ export const selectSession = (state: RootState) => state.session; @@ -8,7 +10,9 @@ export const selectConnectionStatus = (state: RootState) => state.session.status /** Select operation records in display order. */ export const selectOperationRecords = (state: RootState) => - state.operations.order.map((requestId) => state.operations.byId[requestId]); + state.operations.order + .map((requestId) => state.operations.byId[requestId]) + .filter((record): record is OperationRecord => record !== undefined); /** Select currently running operations. */ export const selectActiveOperations = (state: RootState) => @@ -16,18 +20,65 @@ export const selectActiveOperations = (state: RootState) => (operation) => operation?.status === 'running' ); +/** Select active operation count for compact shell indicators. */ +export const selectActiveOperationCount = (state: RootState): number => + selectActiveOperations(state).length; + /** Select the most recent running operation label for the global overlay. */ export const selectLatestActiveOperationLabel = ( state: RootState ): string | null => [...selectActiveOperations(state)].reverse()[0]?.label ?? null; +/** Find one operation record by request id. */ +export const selectOperationById = + (requestId: string) => + (state: RootState): OperationRecord | null => + state.operations.byId[requestId] ?? null; + +/** Return true when a running operation owns any of the supplied resources. */ +export const selectHasActiveResourceConflict = + (resourceKeys: readonly string[]) => + (state: RootState): boolean => { + const requested = new Set(resourceKeys); + return selectActiveOperations(state).some((operation) => + operation.resourceKeys.some((key) => requested.has(key)) + ); + }; + /** Select normalized identifiers in list order. */ export const selectIdentifiers = (state: RootState) => - state.identifiers.prefixes.map( - (prefix) => state.identifiers.byPrefix[prefix] + state.identifiers.prefixes + .map((prefix) => state.identifiers.byPrefix[prefix]) + .filter((identifier) => identifier !== undefined); + +const byNewestTimestamp = ( + left: AppNotificationRecord, + right: AppNotificationRecord +): number => right.createdAt.localeCompare(left.createdAt); + +/** Select user-facing app notification records in descending timestamp order. */ +export const selectAppNotifications = (state: RootState) => + state.appNotifications.ids + .map((id) => state.appNotifications.byId[id]) + .filter( + (notification): notification is AppNotificationRecord => + notification !== undefined + ) + .sort(byNewestTimestamp); + +/** Select unread user-facing app notifications. */ +export const selectUnreadAppNotifications = (state: RootState) => + selectAppNotifications(state).filter( + (notification) => notification?.status === 'unread' ); +/** Select one app notification by id. */ +export const selectAppNotificationById = + (id: string) => + (state: RootState) => + state.appNotifications.byId[id] ?? null; + /** Build an alias lookup for resolved and pending contacts. */ export const selectContactsByAlias = (state: RootState) => { const contacts = Object.values(state.contacts.byId); diff --git a/src/state/store.ts b/src/state/store.ts index bbbee4ca..95dabc4e 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,4 +1,5 @@ import { configureStore } from '@reduxjs/toolkit'; +import { appNotificationsReducer } from './appNotifications.slice'; import { challengesReducer } from './challenges.slice'; import { contactsReducer } from './contacts.slice'; import { credentialsReducer } from './credentials.slice'; @@ -18,6 +19,7 @@ export const createAppStore = () => reducer: { session: sessionReducer, operations: operationsReducer, + appNotifications: appNotificationsReducer, contacts: contactsReducer, challenges: challengesReducer, credentials: credentialsReducer, diff --git a/tests/browser-smoke.mjs b/tests/browser-smoke.mjs index 226d6ce0..c2320f47 100644 --- a/tests/browser-smoke.mjs +++ b/tests/browser-smoke.mjs @@ -115,6 +115,12 @@ try { await page.waitForSelector('[data-testid="identifier-table"]', { timeout: 10000, }); + const identifierTableText = await textContent(page, '[data-testid="identifier-table"]'); + for (const expectedHeader of ['AID', 'Current Key', 'KIDX', 'PIDX', 'Actions']) { + if (!identifierTableText.includes(expectedHeader)) { + throw new Error(`Identifier table is missing ${expectedHeader} header`); + } + } if (!page.url().endsWith('/identifiers')) { throw new Error(`Expected post-connect /identifiers route, got ${page.url()}`); } diff --git a/tests/unit/identifierHelpers.test.ts b/tests/unit/identifierHelpers.test.ts index 7b4e25c4..6ba057e8 100644 --- a/tests/unit/identifierHelpers.test.ts +++ b/tests/unit/identifierHelpers.test.ts @@ -3,9 +3,19 @@ import { Algos, type HabState, type KeyState } from 'signify-ts'; import { buildAppConfig } from '../../src/config'; import { defaultIdentifierCreateDraft, + formatIdentifierMetadata, + identifierCurrentKey, + identifierCurrentKeys, identifierCreateDraftToArgs, + identifierIdentifierIndex, + identifierJson, + identifierKeyIndex, + identifierTier, + identifierType, identifiersFromResponse, isIdentifierCreateDraft, + replaceIdentifierSummary, + truncateMiddle, } from '../../src/features/identifiers/identifierHelpers'; import type { IdentifierCreateDraft } from '../../src/features/identifiers/identifierTypes'; @@ -51,6 +61,22 @@ const identifier = (name: string, prefix: string): HabState => ({ }, }); +const randyIdentifier = (name: string, prefix: string): HabState => ({ + name, + prefix, + icp_dt: '2026-04-21T00:00:00.000000+00:00', + state: { + ...keyState(prefix), + k: [`${prefix}-current-key`], + }, + transferable: true, + windexes: [], + randy: { + prxs: [`${prefix}-public-random`], + nxts: [`${prefix}-next-random`], + }, +}); + describe('identifiersFromResponse', () => { it('normalizes direct identifier arrays', () => { const identifiers = [identifier('alice', 'Ealice')]; @@ -83,6 +109,86 @@ describe('identifiersFromResponse', () => { }); }); +describe('identifier display helpers', () => { + it('extracts salty current key and local key-manager metadata', () => { + const base = identifier('alice', 'Ealice'); + const aid = { + ...base, + state: { + ...keyState('Ealice'), + k: ['Ealice-key-0', 'Ealice-key-1'], + }, + salty: { + ...('salty' in base ? base.salty : undefined), + kidx: 3, + pidx: 2, + tier: 'high', + }, + }; + + expect(identifierType(aid)).toBe('salty'); + expect(identifierCurrentKeys(aid)).toEqual([ + 'Ealice-key-0', + 'Ealice-key-1', + ]); + expect(identifierCurrentKey(aid)).toBe('Ealice-key-0'); + expect(identifierKeyIndex(aid)).toBe(3); + expect(identifierIdentifierIndex(aid)).toBe(2); + expect(identifierTier(aid)).toBe('high'); + }); + + it('does not invent unavailable randy key-manager metadata', () => { + const aid = randyIdentifier('randy', 'Erandy'); + + expect(identifierType(aid)).toBe('randy'); + expect(identifierCurrentKey(aid)).toBe('Erandy-current-key'); + expect(identifierKeyIndex(aid)).toBeNull(); + expect(identifierIdentifierIndex(aid)).toBeNull(); + expect(identifierTier(aid)).toBeNull(); + expect(formatIdentifierMetadata(identifierKeyIndex(aid))).toBe( + 'Unavailable' + ); + }); + + it('formats full identifier JSON for advanced display', () => { + const aid = identifier('alice', 'Ealice'); + + expect(identifierJson(aid)).toContain('"state": {'); + expect(identifierJson(aid)).toContain('"k": ['); + expect(identifierJson(aid)).toContain('"Ealice-key"'); + }); + + it('shortens long AIDs by preserving the first and last eight characters', () => { + expect(truncateMiddle('E123456789abcdefghijklmnop')).toBe( + 'E1234567...ijklmnop' + ); + expect(truncateMiddle('Eshort')).toBe('Eshort'); + }); +}); + +describe('replaceIdentifierSummary', () => { + it('replaces an existing identifier by name with freshly fetched state', () => { + const stale = identifier('alice', 'Ealice'); + const fresh = { + ...stale, + salty: { + ...stale.salty, + kidx: 1, + }, + }; + + expect( + replaceIdentifierSummary([stale, identifier('bob', 'Ebob')], fresh) + ).toEqual([fresh, identifier('bob', 'Ebob')]); + }); + + it('appends a refreshed identifier when it was missing from the list', () => { + const fresh = identifier('alice', 'Ealice'); + + expect(replaceIdentifierSummary([], fresh)).toEqual([fresh]); + }); +}); + describe('identifier create drafts', () => { const config = buildAppConfig({}); diff --git a/tests/unit/pendingState.test.ts b/tests/unit/pendingState.test.ts index 7bb0df83..f7b69ca2 100644 --- a/tests/unit/pendingState.test.ts +++ b/tests/unit/pendingState.test.ts @@ -101,18 +101,17 @@ describe('derivePendingState', () => { }); }); - it('uses active RTK operation facts when router state is idle', () => { + it('does not block the app for background operation facts', () => { expect( derivePendingState({ navigation: idleNavigation, fetchers: [], connectionStatus: 'connected', - activeOperationLabel: 'Resolving contact...', }) ).toEqual({ - active: true, - label: 'Resolving contact...', - source: 'runtime', + active: false, + label: 'Loading...', + source: null, }); }); }); diff --git a/tests/unit/persistence.test.ts b/tests/unit/persistence.test.ts new file mode 100644 index 00000000..42796a6d --- /dev/null +++ b/tests/unit/persistence.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { + installAppStatePersistence, + loadPersistedAppState, + persistedAppStateKey, + rehydratePersistedAppState, + savePersistedAppState, + type AppStateStorage, +} from '../../src/state/persistence'; +import { createAppStore } from '../../src/state/store'; +import { operationStarted } from '../../src/state/operations.slice'; + +class MemoryStorage implements AppStateStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +describe('app state persistence', () => { + it('saves and loads operation history', () => { + const store = createAppStore(); + const storage = new MemoryStorage(); + + store.dispatch( + operationStarted({ + requestId: 'op-1', + label: 'Creating identifier...', + title: 'Create identifier', + kind: 'createIdentifier', + resourceKeys: ['identifier:name:alice'], + startedAt: '2026-04-21T00:00:00.000Z', + }) + ); + savePersistedAppState(store.getState(), 'Econtroller1', storage); + + expect( + loadPersistedAppState('Econtroller1', storage)?.operations + ).toHaveLength(1); + expect(loadPersistedAppState('Econtroller2', storage)).toBeNull(); + }); + + it('rehydrates running operations as interrupted', () => { + const source = createAppStore(); + const target = createAppStore(); + const storage = new MemoryStorage(); + + source.dispatch( + operationStarted({ + requestId: 'op-running', + label: 'Working...', + title: 'Working operation', + kind: 'resolveContact', + resourceKeys: ['contact:alice'], + startedAt: '2026-04-21T00:00:00.000Z', + }) + ); + savePersistedAppState(source.getState(), 'Econtroller1', storage); + rehydratePersistedAppState(target, 'Econtroller1', storage); + + expect(target.getState().operations.byId['op-running']).toMatchObject({ + status: 'interrupted', + }); + }); + + it('clears operation state when a controller has no persisted bucket', () => { + const target = createAppStore(); + const storage = new MemoryStorage(); + + target.dispatch( + operationStarted({ + requestId: 'op-existing', + label: 'Existing...', + title: 'Existing operation', + kind: 'resolveContact', + resourceKeys: ['contact:alice'], + startedAt: '2026-04-21T00:00:00.000Z', + }) + ); + rehydratePersistedAppState(target, 'Econtroller-without-state', storage); + + expect(target.getState().operations.order).toHaveLength(0); + }); + + it('persists subscribed store writes under the active controller key', () => { + const store = createAppStore(); + const storage = new MemoryStorage(); + let controllerAid: string | null = 'Econtroller1'; + const uninstall = installAppStatePersistence( + store, + () => controllerAid, + storage + ); + + store.dispatch( + operationStarted({ + requestId: 'op-controller-1', + label: 'Controller one...', + title: 'Controller one operation', + kind: 'resolveContact', + resourceKeys: ['contact:alice'], + startedAt: '2026-04-21T00:00:00.000Z', + }) + ); + controllerAid = null; + store.dispatch( + operationStarted({ + requestId: 'op-unscoped', + label: 'Unscoped...', + title: 'Unscoped operation', + kind: 'resolveContact', + resourceKeys: ['contact:bob'], + startedAt: '2026-04-21T00:00:01.000Z', + }) + ); + uninstall(); + + expect( + loadPersistedAppState('Econtroller1', storage)?.operations.map( + (operation) => operation.requestId + ) + ).toEqual(['op-controller-1']); + expect(loadPersistedAppState('Econtroller2', storage)).toBeNull(); + }); + + it('ignores invalid persisted data', () => { + const storage = new MemoryStorage(); + storage.setItem(persistedAppStateKey('Econtroller1'), '{not-json'); + + expect(loadPersistedAppState('Econtroller1', storage)).toBeNull(); + }); +}); diff --git a/tests/unit/routeData.test.ts b/tests/unit/routeData.test.ts index 140e37f0..2eaf48f5 100644 --- a/tests/unit/routeData.test.ts +++ b/tests/unit/routeData.test.ts @@ -57,6 +57,16 @@ const makeRuntime = ( ]), createIdentifier: vi.fn(async () => []), rotateIdentifier: vi.fn(async () => []), + startCreateIdentifier: vi.fn(() => ({ + status: 'accepted', + requestId: 'create-request-1', + operationRoute: '/operations/create-request-1', + })), + startRotateIdentifier: vi.fn(() => ({ + status: 'accepted', + requestId: 'rotate-request-1', + operationRoute: '/operations/rotate-request-1', + })), ...overrides, }); @@ -207,13 +217,13 @@ describe('route actions', () => { ).resolves.toEqual({ intent: 'create', ok: true, - message: 'Created identifier alice', + message: 'Creating identifier alice', requestId: 'create-request-1', + operationRoute: '/operations/create-request-1', }); - expect(runtime.createIdentifier).toHaveBeenCalledWith( + expect(runtime.startCreateIdentifier).toHaveBeenCalledWith( draft, expect.objectContaining({ - signal: expect.any(AbortSignal), requestId: 'create-request-1', }) ); @@ -248,16 +258,19 @@ describe('route actions', () => { makeRequest('/identifiers', { intent: 'rotate', aid: 'alice', + requestId: 'rotate-request-1', }) ) ).resolves.toEqual({ intent: 'rotate', ok: true, - message: 'Rotated identifier alice', + message: 'Rotating identifier alice', + requestId: 'rotate-request-1', + operationRoute: '/operations/rotate-request-1', }); - expect(runtime.rotateIdentifier).toHaveBeenCalledWith( + expect(runtime.startRotateIdentifier).toHaveBeenCalledWith( 'alice', - expect.objectContaining({ signal: expect.any(AbortSignal) }) + expect.objectContaining({ requestId: 'rotate-request-1' }) ); }); diff --git a/tests/unit/router.test.ts b/tests/unit/router.test.ts index 40752d22..74d950c2 100644 --- a/tests/unit/router.test.ts +++ b/tests/unit/router.test.ts @@ -34,13 +34,29 @@ describe('data-router route metadata', () => { testId: 'nav-client', path: '/client', }, + { + routeId: 'operations', + label: 'Operations', + gate: 'none', + nav: true, + testId: 'nav-operations', + path: '/operations', + }, + { + routeId: 'appNotifications', + label: 'Notifications', + gate: 'none', + nav: true, + testId: 'nav-notifications', + path: '/notifications', + }, ]); }); it('attaches handles to the feature route objects', () => { const rootRoute = createAppRoutes({} as AppRuntime)[0]; const featureRoutes = rootRoute.children?.filter( - (route) => route.id !== undefined + (route) => route.id !== undefined && route.handle !== undefined ); const routeHandles = APP_NAV_ITEMS.map((item) => ({ routeId: item.routeId, @@ -68,6 +84,14 @@ describe('data-router route metadata', () => { id: 'client', handle: routeHandles[2], }, + { + id: 'operations', + handle: routeHandles[3], + }, + { + id: 'appNotifications', + handle: routeHandles[4], + }, ]); }); }); diff --git a/tests/unit/runtimeWorkflow.test.ts b/tests/unit/runtimeWorkflow.test.ts index 5ae00c8f..664feb79 100644 --- a/tests/unit/runtimeWorkflow.test.ts +++ b/tests/unit/runtimeWorkflow.test.ts @@ -1,5 +1,5 @@ import { sleep } from 'effection'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createAppRuntime } from '../../src/app/runtime'; import { selectActiveOperations } from '../../src/state/selectors'; import { createAppStore } from '../../src/state/store'; @@ -7,7 +7,7 @@ import { createAppStore } from '../../src/state/store'; describe('AppRuntime workflow bridge', () => { it('records successful Effection workflow completion', async () => { const store = createAppStore(); - const runtime = createAppRuntime({ store }); + const runtime = createAppRuntime({ store, storage: null }); await expect( runtime.runWorkflow( @@ -34,7 +34,7 @@ describe('AppRuntime workflow bridge', () => { it('halts an Effection workflow when the route signal aborts', async () => { const store = createAppStore(); - const runtime = createAppRuntime({ store }); + const runtime = createAppRuntime({ store, storage: null }); const controller = new AbortController(); const promise = runtime.runWorkflow( @@ -61,4 +61,88 @@ describe('AppRuntime workflow bridge', () => { await runtime.destroy(); }); + + it('starts background workflows without awaiting completion', async () => { + const store = createAppStore(); + const runtime = createAppRuntime({ store, storage: null }); + + const started = runtime.startBackgroundWorkflow( + function* () { + yield* sleep(0); + return 'done'; + }, + { + requestId: 'background-success', + label: 'Background workflow...', + title: 'Background workflow', + kind: 'resolveContact', + resourceKeys: ['contact:alice'], + resultRoute: { label: 'Contacts', path: '/credentials' }, + successNotification: { + title: 'Background workflow complete', + message: 'The background workflow completed.', + }, + } + ); + + expect(started).toEqual({ + status: 'accepted', + requestId: 'background-success', + operationRoute: '/operations/background-success', + }); + expect(store.getState().operations.byId['background-success']).toMatchObject({ + status: 'running', + resourceKeys: ['contact:alice'], + }); + + await vi.waitFor(() => { + expect(store.getState().operations.byId['background-success']).toMatchObject({ + status: 'success', + notificationId: expect.any(String), + }); + }); + expect(store.getState().appNotifications.ids).toHaveLength(1); + + await runtime.destroy(); + }); + + it('rejects background workflows with active resource conflicts', async () => { + const store = createAppStore(); + const runtime = createAppRuntime({ store, storage: null }); + + runtime.startBackgroundWorkflow( + function* () { + yield* sleep(10_000); + }, + { + requestId: 'background-running', + label: 'Resolving contact...', + title: 'Resolve contact', + kind: 'resolveContact', + resourceKeys: ['contact:alice'], + } + ); + + const conflicted = runtime.startBackgroundWorkflow( + function* () { + yield* sleep(0); + }, + { + requestId: 'background-conflict', + label: 'Resolving contact again...', + title: 'Resolve contact again', + kind: 'resolveContact', + resourceKeys: ['contact:alice'], + } + ); + + expect(conflicted).toEqual({ + status: 'conflict', + requestId: 'background-running', + operationRoute: '/operations/background-running', + message: 'Already working on Resolve contact.', + }); + + await runtime.destroy(); + }); }); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 10c342db..9fa27f99 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -6,9 +6,12 @@ import { operationFailed, operationStarted, operationSucceeded, + operationsRehydrated, } from '../../src/state/operations.slice'; import { selectActiveOperations, + selectAppNotifications, + selectUnreadAppNotifications, selectLatestActiveOperationLabel, selectUnreadNotifications, } from '../../src/state/selectors'; @@ -18,6 +21,10 @@ import { sessionDisconnected, } from '../../src/state/session.slice'; import { notificationRecorded } from '../../src/state/notifications.slice'; +import { + allAppNotificationsRead, + appNotificationRecorded, +} from '../../src/state/appNotifications.slice'; describe('RTK state foundation', () => { it('records session connection facts without live capabilities', () => { @@ -152,4 +159,116 @@ describe('RTK state foundation', () => { store.getState() ); }); + + it('tracks unread app notifications separately from KERIA notifications', () => { + const store = createAppStore(); + + store.dispatch( + appNotificationRecorded({ + id: 'app-n-1', + severity: 'success', + status: 'unread', + title: 'Identifier created', + message: 'The identifier operation completed.', + createdAt: '2026-04-21T00:00:00.000Z', + readAt: null, + operationId: 'op-1', + links: [ + { + rel: 'operation', + label: 'View operation', + path: '/operations/op-1', + }, + ], + }) + ); + + expect(selectUnreadAppNotifications(store.getState())).toHaveLength(1); + expect(selectUnreadNotifications(store.getState())).toHaveLength(0); + }); + + it('selects app notifications newest first and marks them read', () => { + const store = createAppStore(); + + store.dispatch( + appNotificationRecorded({ + id: 'app-n-old', + severity: 'info', + status: 'unread', + title: 'Old', + message: 'Old notification', + createdAt: '2026-04-21T00:00:00.000Z', + readAt: null, + operationId: null, + links: [], + }) + ); + store.dispatch( + appNotificationRecorded({ + id: 'app-n-new', + severity: 'success', + status: 'unread', + title: 'New', + message: 'New notification', + createdAt: '2026-04-21T00:00:01.000Z', + readAt: null, + operationId: null, + links: [], + }) + ); + + expect( + selectAppNotifications(store.getState()).map( + (notification) => notification.id + ) + ).toEqual(['app-n-new', 'app-n-old']); + + store.dispatch( + allAppNotificationsRead({ + readAt: '2026-04-21T00:00:02.000Z', + }) + ); + + expect(selectUnreadAppNotifications(store.getState())).toHaveLength(0); + expect(store.getState().appNotifications.byId['app-n-new']).toMatchObject({ + status: 'read', + readAt: '2026-04-21T00:00:02.000Z', + }); + }); + + it('rehydrates running operations as interrupted', () => { + const store = createAppStore(); + + store.dispatch( + operationsRehydrated({ + interruptedAt: '2026-04-21T00:00:01.000Z', + records: [ + { + requestId: 'op-running', + label: 'Running...', + title: 'Running operation', + description: null, + kind: 'resolveContact', + status: 'running', + phase: 'running', + resourceKeys: ['contact:alice'], + operationRoute: '/operations/op-running', + resultRoute: null, + notificationId: null, + keriaOperationName: null, + startedAt: '2026-04-21T00:00:00.000Z', + finishedAt: null, + error: null, + canceledReason: null, + }, + ], + }) + ); + + expect(store.getState().operations.byId['op-running']).toMatchObject({ + status: 'interrupted', + phase: 'interrupted', + finishedAt: '2026-04-21T00:00:01.000Z', + }); + }); });