Reference
Workstation UI
Routes, the app shell, all twenty-two components and the typed API and formatting helpers.
Next.js 16.3.0 with the App Router, React 19.2.8, TypeScript 5.9.3. output: 'standalone', so the production image ships a self-contained server.js. Icons come from lucide-react; there is no component library and no CSS framework — one hand-written globals.css carries the whole design system.
The app shell
app/layout.tsx is a server component that wraps every route in a fixed shell:
graph LR
subgraph SHELL["appShell — layout.tsx"]
direction LR
subgraph SIDE["aside.sidebar — 240px, fixed"]
direction TB
Mark["productMark<br/><small>links to /</small>"]
Nav["SidebarNav<br/><small>4 groups, 8 links</small>"]
Token["TokenStatus<br/><small>capability + session dots</small>"]
end
subgraph MAIN["div.appMain"]
direction TB
Top["header.topBar — sticky<br/><small>Government of Andhra Pradesh / eFile approval workstation</small>"]
Chip["OfficerChip<br/><small>username + logout</small>"]
Content["main.appContent<br/><small>the route renders here</small>"]
end
end
Mark --- Nav --- Token
Top --- Content
Top -.-> Chip
click Nav "/workstation" "Navigation"
click Token "/web-sdk" "Web SDK reference"
classDef chrome fill:#eef0f2,stroke:#4a5568,color:#18221d
classDef route fill:#e4f4ec,stroke:#0d6b4f,color:#18221d
class Mark,Nav,Token,Top,Chip chrome
class Content route
It also injects the ceremony client before hydration:
<Script src="/q/webauthn/webauthn.js" strategy="beforeInteractive" />
beforeInteractive is load-bearing. TokenStatus calls sdk.getCapabilities() from an effect on the very first paint, and if window.WebAuthn is not yet defined it reports frameworkClient: false and the sidebar shows "WebAuthn unavailable" until a reload.
Routes
Eight, all under src/app. Each page file is thin — it renders a header and delegates to one view component.
| Route | View component | Data source |
|---|---|---|
/ |
dashboard/dashboard-view.tsx |
GET /dashboard, GET /files, GET /audit |
/files |
files/file-inbox-view.tsx |
GET /files with filters |
/files/[id] |
files/file-detail-view.tsx |
GET /files/{id}, POST /files/{id}/action |
/verify |
verify/verify-view.tsx |
POST /verify |
/audit |
audit/audit-view.tsx |
GET /audit |
/devices |
devices/devices-view.tsx |
GET /devices |
/developer |
developer-section.tsx |
static |
/about |
five section components | static |
Every data-bearing view is a client component that fetches in an effect. There is no server-side data fetching, no React Server Component data access, no caching layer and no revalidate. lib/api.ts sets cache: 'no-store' on every request, so each navigation refetches.
That is a defensible choice here — the session cookie lives in the browser and the read endpoints are cheap — but it means the first paint of every screen is a loading state, and it forgoes streaming and prefetching entirely.
Navigation
SidebarNav is the only client component in the shell that reads the route. Four groups, eight destinations:
| Group | Items |
|---|---|
| Workspace | Dashboard, File Inbox |
| Compliance | Verify Signature, Audit Trail |
| Administration | Signing Devices |
| Resources | Developer SDK, About |
The grouping is the information architecture argument: an officer's daily work is Workspace, an auditor's is Compliance, an administrator's is Administration. It is presentation only — the security layer issues one role, so the groups organise the screens rather than gate them.
Active state handles the nested file route correctly:
function isActive(pathname: string, href: string): boolean {
return href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(`${href}/`);
}
The href === '/' special case stops Dashboard matching every route, and the startsWith keeps File Inbox highlighted while you are on /files/3.
aria-current="page" is set on the active link, so the state is exposed to assistive technology rather than being colour alone.
The 22 components
App shell — 3
| Component | Client | Purpose |
|---|---|---|
app-shell/sidebar-nav.tsx |
yes | grouped primary navigation with active state |
app-shell/token-status.tsx |
yes | two-dot capability and session indicator |
app-shell/officer-chip.tsx |
yes | signed-in username and logout |
TokenStatus is the honest status widget. It resolves capability and session concurrently and tolerates either failing:
Promise.allSettled([sdk.getCapabilities(), sdk.getSession()]).then(([caps, session]) => { … });
allSettled rather than all matters — an unreachable backend must not blank out the capability line, which is purely local.
It reports two independent facts. The capability dot combines webAuthn && secureContext; the session dot is whether a username came back. Distinguishing them is what stops "no session" from being misread as "your key is broken".
Note the capability line applies secureContext, which getCapabilities()'s own capabilities:checked event detail ignores. The widget is stricter than the SDK event.
Files — 4
| Component | Purpose |
|---|---|
files/file-inbox-view.tsx |
filterable full-width table of every file |
files/file-detail-view.tsx |
the centrepiece: document, notings, action rail |
files/noting-sheet.tsx |
the threaded noting list |
files/sdk-trace.tsx |
live SDK lifecycle event panel |
The inbox column order puts the decision first:
Status → Priority → File number → Subject → Category → Department → Amount → Notes → Initiated
Status leads because triage is the first question an officer asks. Filters are status, priority and a search box bound to q, passed straight to GET /files — filtering is server-side, so the table always reflects the backend's ordering rules rather than re-sorting locally.
FileDetailView is the only component that writes. It holds the SDK instance, the auth panel, the three decision buttons, the note textarea and the trace panel, and on every successful action it replaces its entire file state with the FileDetail from the response. That is why the status badge and the new noting appear together and cannot drift.
The decision controls are disabled until GET /session reports authenticated: true. The gate is server truth, not a local "I think I registered" flag.
SdkTrace subscribes to the SDK and renders events as they arrive. The decision path goes through sdk.signFile(), so a signature emits document:hashed followed by signature:recorded and both appear in the trace — the panel reflects the real ceremony rather than a narration of it.
FileDetailView also calls installBridge({ sdk }), sharing its instance, so window.MobileSigner is available on this screen and anything a host page triggers through it lands in the same trace. See Web SDK.
After a signature the rail shows the measured cost of the ceremony — SHA-256, bind-and-record, and total — taken from the SignFileResult rather than estimated.
Views — 4
| Component | Purpose |
|---|---|
dashboard/dashboard-view.tsx |
six KPIs, the priority queue, recent activity |
verify/verify-view.tsx |
digest input, verdict, six checks, signer certificate |
audit/audit-view.tsx |
audit table with an event-type filter |
devices/devices-view.tsx |
device inventory with a state filter |
VerifyView reads ?hash= from the query string, which is what makes Verify this signature on the file detail screen a working deep link rather than a copy-paste instruction.
Marketing and reference — 6
Used only by /about and /developer: problem-section.tsx, solution-section.tsx, mobile-ux-section.tsx, compliance-section.tsx, comparison-section.tsx, developer-section.tsx.
These are the descendants of the original landing page. They are now confined to two routes so the operational screens are not competing with pitch copy.
Auth — 1
auth-panel.tsx — username field, Enrol token and Authenticate, capability warnings, and error rendering from MobileSignerError. Embedded in the file detail action rail rather than being a separate login route, so the officer authenticates in the context of the decision they are making.
UI primitives — 4
| Component | Exports |
|---|---|
ui/badge.tsx |
Badge, priorityTone, statusTone, deviceTone, outcomeTone, eventTone |
ui/data-table.tsx |
DataTable, TableHead, Card, DefinitionGrid |
ui/empty-state.tsx |
EmptyState, LoadingState, ErrorState |
ui/page-header.tsx |
PageHeader |
empty-state.tsx gives all four screens the same three-state contract — loading, empty, error — with correct semantics: role="status" on loading and empty, role="alert" on error, and an optional onRetry so a transient backend failure is recoverable without a reload.
ApiError with status: 0 is the specific case worth noting: lib/api.ts maps a network-level failure to Backend service unreachable. Check that the platform API is running. rather than an HTTP code the officer cannot act on.
Badge tones
Five tones — green, red, amber, slate, blue — with five mapping functions, each falling back to slate for unknown values.
| Map | Keys |
|---|---|
PRIORITY_TONES |
MOST_IMMEDIATE red, IMMEDIATE amber, ROUTINE slate |
STATUS_TONES |
PENDING amber, APPROVED green, REJECTED red, RETURNED blue |
DEVICE_TONES |
ACTIVE green, PROVISIONED blue, REGISTERED slate, REVOKED red |
EVENT_TONES |
see below |
outcomeTone |
SUCCESS green, FAILURE red, else slate |
EVENT_TONES covers the file and signature events, and anything else takes the slate fallback:
const EVENT_TONES: Record<string, Tone> = {
FILE_APPROVED: 'green',
FILE_REJECTED: 'red',
FILE_RETURNED: 'blue',
SIGNATURE_VERIFIED: 'green',
CREDENTIAL_REGISTERED: 'blue',
AUTHENTICATION_SUCCEEDED: 'green',
AUTHENTICATION_FAILED: 'red',
DEVICE_REVOKED: 'red'
};
The backend's authentication rows use the type AUTH_SUCCESS, which is not a key here, so they render slate. Outcome is coloured separately by outcomeTone, which is what makes a failed authentication legible: a neutral event badge beside a red FAILURE badge. If you extend the tone map, key it on the types the backend actually emits — Backend API lists them.
lib/api.ts
The typed client. const BASE = '/backend', nine exported functions, seventeen exported types.
| Function | Call |
|---|---|
getSession() |
GET /session |
getDashboard() |
GET /dashboard |
listFiles(filters) |
GET /files with status, priority, q |
getFile(id) |
GET /files/{id} |
fileAction(id, input) |
POST /files/{id}/action |
listAudit(filters) |
GET /audit with type, limit |
listDevices(filters) |
GET /devices with state |
verifySignature(hash) |
POST /verify |
errorMessage(error, fallback) |
local helper |
Plus the ApiError class carrying status.
Two details that make it well-behaved:
The query() helper drops empty values rather than sending blanks, so listFiles({status: ''}) requests /files and not /files?status=:
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) search.set(key, text);
}
Content-Type: application/json is only set when there is a body, which avoids sending it on GETs.
verifySignature normalises before sending — documentHash.trim().toLowerCase() — matching what the backend does anyway, so a digest pasted with stray whitespace or in upper case works.
Nullability worth knowing
Three declared types are narrower than the payload they receive:
| Type | Declared | Actual |
|---|---|---|
AuditRecord.targetId |
string |
nullable, and null on every AUTH_SUCCESS row |
SigningDevice.provisionedCa |
string |
nullable, and null for MS-2026-D4A6B520 |
FileSummary.noteCount |
number |
long server-side, fine in practice |
On the first two, the declared type is narrower than the payload, so treat them as nullable at the render site even though the compiler will not insist.
The union types for priority, status, noteType and device state are widened with | string — priority: FilePriority | string. That keeps an unrecognised server value from becoming a type error, which is why the badge helpers all carry a slate fallback. The trade-off is that a switch over one of them is not checked for exhaustiveness.
lib/format.ts
Six exported functions. All handle null and undefined and return '—' rather than throwing or rendering "Invalid Date".
| Function | Output |
|---|---|
relativeTime(iso) |
just now, 14m ago, 19h ago, 4d ago, 3mo ago, 2y ago, scheduled for the future |
formatDateTime(iso) |
10 Aug 2026, 14:32 |
formatDate(iso) |
10 Aug 2026 |
truncate(value, n) |
clipped with a trailing …, counted into the limit |
formatDuration(minutes) |
42m, 1h 35m, 2h, — for zero |
humanise(code) |
MOST_IMMEDIATE → Most immediate |
en-GB with hour12: false throughout — day-month-year and a 24-hour clock, which is what Indian government correspondence uses. The Intl.DateTimeFormat instance for formatDateTime is constructed once at module scope; formatDate constructs a new one per call, which is a small avoidable cost.
relativeTime returns scheduled for a negative delta instead of "in 3 days", which is the right call for an audit trail where a future timestamp means a clock problem, not a scheduled event.
humanise is where the eOffice colour convention is translated:
GREEN: 'Permanent note',
YELLOW: 'Draft note'
An officer sees "Permanent note" and "Draft note", not a colour word. The fallback for an unmapped code — code.toLowerCase().replace(/_/g, ' ') with the first letter capitalised — is why an unknown status still renders readably.