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 { provisionCapabilityStatus, sealProvisionCapability } from "../runtime/provision.ts";6import { enrollSyncTicket, type Session } from "../runtime/session.ts";78/** Dependencies {@link TicketEnrollForm} accepts for testing and composition. */9export interface TicketEnrollFormProps {10 /** Called with the new session after a successful enrollment. */11 readonly onEnrolled?: (session: Session) => void;12 /** Heading rendered above the form. */13 readonly title?: string;14 /** Enrollment implementation; defaults to the runtime's `enrollSyncTicket`. */15 readonly enroll?: (ticket: string) => Promise<Session>;16 /** Sealing implementation; defaults to the runtime's `sealProvisionCapability`. */17 readonly seal?: () => Promise<{ portable: boolean }>;18}1920type Phase =21 | { name: "edit"; problem?: string }22 | { name: "enrolling" }23 | { name: "enrolled"; sealOffer: boolean }24 | { name: "sealing" }25 | { name: "sealed"; portable: boolean }26 | { name: "custody"; reason: "prf-unavailable" | "cancelled" };2728// Asks the browser's password manager to save the ticket against this origin29// explicitly (Chromium honors the Credential Management call; other engines30// rely on the form heuristics the markup below satisfies). Best-effort — a31// refusal changes nothing about enrollment.32async function offerTicketToPasswordManager(label: string, ticket: string): Promise<void> {33 const scope = globalThis as unknown as {34 PasswordCredential?: new (init: { id: string; password: string }) => Credential;35 navigator?: { credentials?: { store?: (credential: Credential) => Promise<unknown> } };36 };37 if (!scope.PasswordCredential || !scope.navigator?.credentials?.store) return;38 try {39 await scope.navigator.credentials.store(40 new scope.PasswordCredential({ id: label, password: ticket }),41 );42 } catch {43 // The manager declined or the surface is unavailable; the form heuristics44 // remain the save path.45 }46}4748/**49 * The app-connect ticket enrollment form, shaped for password managers: the50 * ticket is a `current-password` field with a label-as-username companion, so51 * the manager offers to save the ticket against this origin on first paste52 * and autofills it later. That custody is the durable copy on devices that53 * cannot seal admin capability behind a passkey — the node stores only54 * hashes, so the manager's copy is the one that survives.55 *56 * After enrolling a provision-scoped ticket whose capability was split (see57 * `provision.ts`), the form offers the passkey-sealing ceremony, and states58 * the password-manager fallback when the ceremony reports `prf-unavailable`59 * or is cancelled.60 *61 * @example62 * ```tsx63 * import { TicketEnrollForm } from "@nzip/lofi/preact";64 *65 * <TicketEnrollForm title="Connect to your node" />;66 * ```67 *68 * @param props Optional callbacks, heading, and injectable implementations.69 * @returns The enrollment form and its follow-on custody choices.70 */71export function TicketEnrollForm({72 onEnrolled,73 title = "Connect a sync location",74 enroll = enrollSyncTicket,75 seal = sealProvisionCapability,76}: TicketEnrollFormProps): VNode {77 const [phase, setPhase] = useState<Phase>({ name: "edit" });78 const [ticket, setTicket] = useState("");79 const [label, setLabel] = useState("");80 const [labelEdited, setLabelEdited] = useState(false);8182 function onTicketInput(value: string): void {83 setTicket(value);84 if (!labelEdited) {85 const parsed = parseSyncTicket(value);86 if (parsed?.label) setLabel(parsed.label);87 }88 }8990 async function onSubmit(event: Event): Promise<void> {91 event.preventDefault();92 setPhase({ name: "enrolling" });93 try {94 const session = await enroll(ticket);95 await offerTicketToPasswordManager(label || "sync ticket", ticket);96 const provision = provisionCapabilityStatus();97 setPhase({ name: "enrolled", sealOffer: provision.held && !provision.sealed });98 onEnrolled?.(session);99 } catch (error) {100 const problem = isDataSinkError(error)101 ? error.message102 : "Enrollment failed; check the ticket and the node, then paste it again.";103 setPhase({ name: "edit", problem });104 }105 }106107 async function onSeal(): Promise<void> {108 setPhase({ name: "sealing" });109 try {110 const { portable } = await seal();111 setPhase({ name: "sealed", portable });112 } catch (error) {113 if (error instanceof AuthError && error.code === "cancelled") {114 setPhase({ name: "custody", reason: "cancelled" });115 return;116 }117 setPhase({ name: "custody", reason: "prf-unavailable" });118 }119 }120121 if (phase.name === "enrolled" && phase.sealOffer) {122 return (123 <section>124 <h2>{title}</h2>125 <p>Connected. This ticket also carries admin access to the store.</p>126 <button type="button" onClick={() => void onSeal()}>127 Protect admin access with your passkey128 </button>129 <button130 type="button"131 onClick={() => setPhase({ name: "enrolled", sealOffer: false })}132 >133 Keep it in my password manager instead134 </button>135 <p>136 Either way, admin access is never stored on this device without protection; syncing137 continues on its own.138 </p>139 </section>140 );141 }142143 if (phase.name === "sealed") {144 return (145 <section>146 <h2>{title}</h2>147 <p>148 Admin access is sealed behind your passkey149 {phase.portable150 ? ", which syncs across your devices with your passkey provider."151 : ", which stays on this device."} Unlocking it will ask for the passkey.152 </p>153 </section>154 );155 }156157 if (phase.name === "custody") {158 return (159 <section>160 <h2>{title}</h2>161 <p>162 {phase.reason === "prf-unavailable"163 ? "This device cannot seal admin access behind a passkey."164 : "Passkey sealing was cancelled."}{" "}165 Admin access stays available until this page closes; keep the ticket in your password166 manager and paste it again when you administer the store. Syncing continues on its own.167 </p>168 </section>169 );170 }171172 if (phase.name === "enrolled" || phase.name === "sealing") {173 return (174 <section>175 <h2>{title}</h2>176 <p>{phase.name === "sealing" ? "Waiting for your passkey…" : "Connected and syncing."}</p>177 </section>178 );179 }180181 return (182 <section>183 <h2>{title}</h2>184 <form onSubmit={(event) => void onSubmit(event)}>185 <label>186 Name for this connection187 <input188 type="text"189 name="username"190 autocomplete="username"191 value={label}192 onInput={(event) => {193 setLabel((event.target as HTMLInputElement).value);194 setLabelEdited(true);195 }}196 />197 </label>198 <label>199 App-connect ticket200 <input201 type="password"202 name="ticket"203 autocomplete="current-password"204 required205 value={ticket}206 onInput={(event) => onTicketInput((event.target as HTMLInputElement).value)}207 />208 </label>209 <button type="submit" disabled={phase.name === "enrolling"}>210 {phase.name === "enrolling" ? "Connecting…" : "Connect"}211 </button>212 {phase.name === "edit" && phase.problem ? <p role="alert">{phase.problem}</p> : null}213 </form>214 <p>215 The ticket is a bearer credential; let your password manager save it here so it can fill it216 next time.217 </p>218 </section>219 );220}