clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-effect-library
lofi / package / preact / TicketEnrollForm.tsx
TicketEnrollForm.tsx 8.4K
1import type { VNode } from "preact";2import { useState } from "preact/hooks";3import { AuthError } from "../runtime/auth.ts";4import { isDataSinkError, parseSyncTicket } from "../runtime/data-sink.ts";5import {6 provisionCapabilityStatus,7 type SealOutcome,8 sealProvisionCapability,9} from "../runtime/provision.ts";10import { enrollSyncTicket, isSyncEnrollmentError, type Session } from "../runtime/session.ts";11import { isSyncOwnerError } from "../runtime/sync-owner.ts";1213/** Dependencies {@link TicketEnrollForm} accepts for testing and composition. */14export interface TicketEnrollFormProps {15 /** Called with the new session after a successful enrollment. */16 readonly onEnrolled?: (session: Session) => void;17 /** Heading rendered above the form. */18 readonly title?: string;19 /** Enrollment implementation; defaults to the runtime's `enrollSyncTicket`. */20 readonly enroll?: (ticket: string) => Promise<Session>;21 /** Sealing implementation; defaults to the runtime's `sealProvisionCapability`. */22 readonly seal?: () => Promise<SealOutcome>;23}2425/**26 * Turns an enrollment failure into the sentence the form shows. The typed27 * refusals carry user-presentable messages that name their remediation — a28 * refused store preflight ({@link isSyncEnrollmentError}), a sync election29 * owned by another account ({@link isSyncOwnerError}), and malformed or30 * conflicting tickets ({@link isDataSinkError}) — so those pass through31 * verbatim; only an unrecognized failure gets the generic retry line.32 * Exported for tests; the entry does not re-export it.33 */34export function describeEnrollmentProblem(error: unknown): string {35 if (isSyncEnrollmentError(error) || isSyncOwnerError(error) || isDataSinkError(error)) {36 return error.message;37 }38 return "Enrollment failed; check the ticket and the node, then paste it again.";39}4041type Phase =42 | { name: "edit"; problem?: string }43 | { name: "enrolling" }44 | { name: "enrolled"; sealOffer: boolean }45 | { name: "sealing" }46 | { name: "sealed"; portable: boolean }47 | { name: "custody"; reason: "prf-unavailable" | "cancelled" };4849// Asks the browser's password manager to save the ticket against this origin50// explicitly (Chromium honors the Credential Management call; other engines51// rely on the form heuristics the markup below satisfies). Best-effort — a52// refusal changes nothing about enrollment.53async function offerTicketToPasswordManager(label: string, ticket: string): Promise<void> {54 const scope = globalThis as unknown as {55 PasswordCredential?: new (init: { id: string; password: string }) => Credential;56 navigator?: { credentials?: { store?: (credential: Credential) => Promise<unknown> } };57 };58 if (!scope.PasswordCredential || !scope.navigator?.credentials?.store) return;59 try {60 await scope.navigator.credentials.store(61 new scope.PasswordCredential({ id: label, password: ticket }),62 );63 } catch {64 // The manager declined or the surface is unavailable; the form heuristics65 // remain the save path.66 }67}6869/**70 * The app-connect ticket enrollment form, shaped for password managers: the71 * ticket is a `current-password` field with a label-as-username companion, so72 * the manager offers to save the ticket against this origin on first paste73 * and autofills it later. That custody is the durable copy on devices that74 * cannot seal admin capability behind a passkey — the node stores only75 * hashes, so the manager's copy is the one that survives.76 *77 * After enrolling a provision-scoped ticket whose capability was split (see78 * `provision.ts`), the form offers the passkey-sealing ceremony, and states79 * the password-manager fallback when the ceremony reports `prf-unavailable`80 * or is cancelled.81 *82 * @example83 * ```tsx84 * import { TicketEnrollForm } from "@nzip/lofi/preact";85 *86 * <TicketEnrollForm title="Connect to your node" />;87 * ```88 *89 * @param props Optional callbacks, heading, and injectable implementations.90 * @returns The enrollment form and its follow-on custody choices.91 */92export function TicketEnrollForm({93 onEnrolled,94 title = "Connect a sync location",95 enroll = enrollSyncTicket,96 seal = sealProvisionCapability,97}: TicketEnrollFormProps): VNode {98 const [phase, setPhase] = useState<Phase>({ name: "edit" });99 const [ticket, setTicket] = useState("");100 const [label, setLabel] = useState("");101 const [labelEdited, setLabelEdited] = useState(false);102103 function onTicketInput(value: string): void {104 setTicket(value);105 if (!labelEdited) {106 const parsed = parseSyncTicket(value);107 if (parsed?.label) setLabel(parsed.label);108 }109 }110111 async function onSubmit(event: Event): Promise<void> {112 event.preventDefault();113 setPhase({ name: "enrolling" });114 try {115 const session = await enroll(ticket);116 await offerTicketToPasswordManager(label || "sync ticket", ticket);117 const provision = provisionCapabilityStatus();118 setPhase({ name: "enrolled", sealOffer: provision.held && !provision.sealed });119 onEnrolled?.(session);120 } catch (error) {121 setPhase({ name: "edit", problem: describeEnrollmentProblem(error) });122 }123 }124125 async function onSeal(): Promise<void> {126 setPhase({ name: "sealing" });127 try {128 const { portable } = await seal();129 setPhase({ name: "sealed", portable });130 } catch (error) {131 if (error instanceof AuthError && error.code === "cancelled") {132 setPhase({ name: "custody", reason: "cancelled" });133 return;134 }135 setPhase({ name: "custody", reason: "prf-unavailable" });136 }137 }138139 if (phase.name === "enrolled" && phase.sealOffer) {140 return (141 <section>142 <h2>{title}</h2>143 <p>Connected. This ticket also carries admin access to the store.</p>144 <button type="button" onClick={() => void onSeal()}>145 Protect admin access with your passkey146 </button>147 <button148 type="button"149 onClick={() => setPhase({ name: "enrolled", sealOffer: false })}150 >151 Keep it in my password manager instead152 </button>153 <p>154 Either way, admin access is never stored on this device without protection; syncing155 continues on its own.156 </p>157 </section>158 );159 }160161 if (phase.name === "sealed") {162 return (163 <section>164 <h2>{title}</h2>165 <p>166 Admin access is sealed behind your passkey167 {phase.portable168 ? ", which syncs across your devices with your passkey provider."169 : ", which stays on this device."} Unlocking it will ask for the passkey.170 </p>171 </section>172 );173 }174175 if (phase.name === "custody") {176 return (177 <section>178 <h2>{title}</h2>179 <p>180 {phase.reason === "prf-unavailable"181 ? "This device cannot seal admin access behind a passkey."182 : "Passkey sealing was cancelled."}{" "}183 Admin access stays available until this page closes; keep the ticket in your password184 manager and paste it again when you administer the store. Syncing continues on its own.185 </p>186 </section>187 );188 }189190 if (phase.name === "enrolled" || phase.name === "sealing") {191 return (192 <section>193 <h2>{title}</h2>194 <p>{phase.name === "sealing" ? "Waiting for your passkey…" : "Connected and syncing."}</p>195 </section>196 );197 }198199 return (200 <section>201 <h2>{title}</h2>202 <form onSubmit={(event) => void onSubmit(event)}>203 <label>204 Name for this connection205 <input206 type="text"207 name="username"208 autocomplete="username"209 value={label}210 onInput={(event) => {211 setLabel((event.target as HTMLInputElement).value);212 setLabelEdited(true);213 }}214 />215 </label>216 <label>217 App-connect ticket218 <input219 type="password"220 name="ticket"221 autocomplete="current-password"222 required223 value={ticket}224 onInput={(event) => onTicketInput((event.target as HTMLInputElement).value)}225 />226 </label>227 <button type="submit" disabled={phase.name === "enrolling"}>228 {phase.name === "enrolling" ? "Connecting…" : "Connect"}229 </button>230 {phase.name === "edit" && phase.problem ? <p role="alert">{phase.problem}</p> : null}231 </form>232 <p>233 The ticket is a bearer credential; let your password manager save it here so it can fill it234 next time.235 </p>236 </section>237 );238}