1import { useCallback, useEffect, useState } from "preact/hooks";2import {3 confirmPhraseAccess,4 createBackupPasskey,5 createRecoverablePasskeyBackup,6 enableSyncBackup,7 isAccountReplacementError,8 isAuthError,9 isRecoverablePasskeyError,10 isRecoveryError,11 readAccountSession,12 restoreFromPasskey,13 restoreFromRecoveryPhrase,14 revealRecoveryPhrase,15 type Session,16 stopSyncBackup,17} from "@nzip/lofi";18import { encodeSharingIdentity } from "@nzip/lofi/access";1920/**21 * Author-owned account example. lofi is local-first: the app already opened on a22 * device-local account with no sign-in. This island offers the *opt-in* upgrade —23 * back up and sync the account, and recover it elsewhere — using only the24 * public `@nzip/lofi` session primitives. Delete it if your app is local-only,25 * or restyle it freely; framework implementation remains package-owned.26 *27 * It renders only when a managed Jazz app is configured (`JAZZ_APP_ID` /28 * `JAZZ_SERVER_URL`, e.g. via `deno task jazz:provision`), because a recovery29 * phrase only matters once there is somewhere to sync to.30 */3132type Busy =33 | "enable"34 | "stop"35 | "reveal"36 | "restore"37 | "passkey-restore"38 | "recoverable"39 | "protect";4041// Turns any thrown value into a sentence a person can act on.42function describe(error: unknown): string {43 if (isAuthError(error)) {44 switch (error.code) {45 case "cancelled":46 return "Passkey prompt dismissed — your recovery phrase was not shown.";47 case "unsupported":48 return "This browser does not support passkeys.";49 default:50 return error.message;51 }52 }53 if (isRecoveryError(error)) return error.message;54 if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message;55 return error instanceof Error ? error.message : String(error);56}5758export default function AccountGate() {59 const [session, setSession] = useState<Session | null>(null);60 const [busy, setBusy] = useState<Busy | null>(null);61 const [error, setError] = useState<string | null>(null);62 const [phrase, setPhrase] = useState<string | null>(null);63 const [phraseInput, setPhraseInput] = useState("");64 // Backup happens in two steps: reveal the phrase first, then enable sync65 // only after the user confirms it is saved — enabling sync reloads the66 // document, which would wipe an unread phrase from the screen.67 const [pendingEnable, setPendingEnable] = useState(false);68 const [restoring, setRestoring] = useState(false);69 const [confirmReplacement, setConfirmReplacement] = useState(false);70 const sharingIdentity = session?.user_id ? encodeSharingIdentity(session.user_id) : null;71 // Set when a backup proceeded without a passkey guard (WebAuthn unavailable),72 // so the phrase block can say so honestly rather than imply a confirmation.73 const [unguarded, setUnguarded] = useState(false);7475 useEffect(() => {76 void readAccountSession().then(setSession, (cause) => setError(describe(cause)));77 }, []);7879 // Every account action funnels through here: one busy flag, one error line,80 // and any returned Session becomes the new snapshot.81 const run = useCallback(82 (kind: Busy, action: () => Promise<Session | void>) => {83 setBusy(kind);84 setError(null);85 void action().then(86 (result) => {87 if (result) setSession(result);88 },89 (cause) => setError(describe(cause)),90 ).finally(() => setBusy(null));91 },92 [],93 );9495 // Nothing to back up to when no Jazz app is configured — stay out of the way.96 if (!session || !session.syncAvailable) return null;9798 const disabled = busy !== null;99100 const phraseBlock = phrase && (101 <div class="account-phrase">102 <p class="account-note">103 Write these {phrase.split(" ").length}{" "}104 words down and keep them somewhere safe. Anyone with them can open this account; lose every105 copy and the account cannot be recovered.106 </p>107 {unguarded && (108 <p class="account-note">109 A passkey could not be created on this browser or origin, so this reveal was not confirmed110 with one. Save the phrase carefully.111 </p>112 )}113 <ol class="account-words" aria-label="Recovery phrase">114 {phrase.split(" ").map((word, index) => <li key={index}>{word}</li>)}115 </ol>116 </div>117 );118119 if (session.backedUp) {120 return (121 <section class="account account-in" aria-labelledby="account-title">122 <header>123 <p class="eyebrow">Account</p>124 <h2 id="account-title">Backed up & syncing</h2>125 </header>126 <p>127 This account replicates to your managed Jazz app and can be recovered with its phrase.128 {session.passkeyRecoverable129 ? " Its account-recovery passkey can restore the same identity where your passkey provider makes it available."130 : " Create an account-recovery passkey if this browser supports one."}131 </p>132 {sharingIdentity && (133 <p class="account-note">134 Share identity: <code data-sharing-identity>{sharingIdentity}</code>135 </p>136 )}137 <div class="account-actions">138 {!session.passkeyRecoverable && (139 <button140 type="button"141 disabled={disabled}142 onClick={() =>143 run("recoverable", async () => {144 await createRecoverablePasskeyBackup();145 return readAccountSession();146 })}147 >148 {busy === "recoverable" ? "Creating…" : "Create account-recovery passkey"}149 </button>150 )}151 <button152 type="button"153 disabled={disabled}154 onClick={() => {155 if (phrase) {156 setPhrase(null);157 return;158 }159 run("reveal", async () => {160 await confirmPhraseAccess();161 setPhrase(await revealRecoveryPhrase());162 });163 }}164 >165 {busy === "reveal" ? "Revealing…" : phrase ? "Hide phrase" : "Show recovery phrase"}166 </button>167 {!session.phraseGuarded && (168 <button169 type="button"170 class="account-secondary"171 disabled={disabled}172 onClick={() =>173 run("protect", async () => {174 setUnguarded(!(await createBackupPasskey()));175 return readAccountSession();176 })}177 >178 {busy === "protect" ? "Setting up…" : "Add local phrase-reveal guard"}179 </button>180 )}181 <button182 type="button"183 class="account-secondary"184 disabled={disabled}185 onClick={() => {186 setPhrase(null);187 run("stop", stopSyncBackup);188 }}189 >190 {busy === "stop" ? "Stopping…" : "Stop syncing"}191 </button>192 </div>193 {phraseBlock}194 {error && <p class="account-error" role="alert">{error}</p>}195 </section>196 );197 }198199 return (200 <section class="account account-out" aria-labelledby="account-title">201 <header>202 <p class="eyebrow">Account</p>203 <h2 id="account-title">Back up & sync</h2>204 </header>205 <p>206 You are working on a private, on-device account — no sign-in, nothing sent anywhere. Back it207 up to sync across your devices and recover it if this one is lost. Your existing data comes208 along. A recoverable passkey is the quick return path; the recovery phrase remains the209 portable bearer-secret fallback.210 </p>211 <div class="account-actions">212 {!pendingEnable && (213 <button214 type="button"215 disabled={disabled}216 onClick={() =>217 run("enable", async () => {218 try {219 await createRecoverablePasskeyBackup();220 setUnguarded(false);221 } catch (cause) {222 if (!isRecoverablePasskeyError(cause) || cause.code !== "unsupported") {223 throw cause;224 }225 setUnguarded(true);226 }227 setPhrase(await revealRecoveryPhrase());228 setPendingEnable(true);229 })}230 >231 {busy === "enable" ? "Backing up…" : "Back up & enable sync"}232 </button>233 )}234 <button235 type="button"236 class="account-secondary"237 disabled={disabled || !confirmReplacement}238 onClick={() => {239 setRestoring(true);240 run("passkey-restore", () =>241 restoreFromPasskey({ confirmLocalReplacement: confirmReplacement }).then((next) => {242 setConfirmReplacement(false);243 return next;244 }));245 }}246 >247 {busy === "passkey-restore" ? "Restoring…" : "Use passkey"}248 </button>249 <button250 type="button"251 class="account-secondary"252 disabled={disabled || !confirmReplacement}253 onClick={() => {254 setError(null);255 setRestoring((value) =>256 !value257 );258 }}259 >260 {restoring ? "Cancel" : "Restore from recovery phrase"}261 </button>262 </div>263 <label class="account-note">264 <input265 type="checkbox"266 checked={confirmReplacement}267 disabled={disabled}268 onChange={(event) => setConfirmReplacement(event.currentTarget.checked)}269 />{" "}270 I understand restore replaces this device's current local account and unsynced data may be271 lost.272 </label>273 {phraseBlock}274 {pendingEnable && (275 <div class="account-actions">276 <p class="account-note">277 Sync starts once you confirm the phrase is saved — the page reloads and the phrase will278 not be shown again automatically.279 </p>280 <button281 type="button"282 disabled={disabled}283 onClick={() => run("enable", enableSyncBackup)}284 >285 {busy === "enable" ? "Enabling sync…" : "I saved my phrase — enable sync"}286 </button>287 </div>288 )}289 {restoring && (290 <div class="account-field">291 <label for="recovery-input">Recovery phrase</label>292 <textarea293 id="recovery-input"294 value={phraseInput}295 onInput={(event) => setPhraseInput(event.currentTarget.value)}296 placeholder="word one word two …"297 rows={3}298 disabled={disabled}299 />300 <p class="account-note">301 Restoring replaces this device's local account with the recovered one — any data created302 here that is not backed up will be discarded.303 </p>304 <button305 type="button"306 disabled={disabled || phraseInput.trim().length === 0}307 onClick={() =>308 run(309 "restore",310 () =>311 restoreFromRecoveryPhrase(phraseInput, {312 confirmLocalReplacement: confirmReplacement,313 }).then((next) => {314 setRestoring(false);315 setPhraseInput("");316 setConfirmReplacement(false);317 return next;318 }),319 )}320 >321 {busy === "restore" ? "Restoring…" : "Restore account"}322 </button>323 </div>324 )}325 {error && <p class="account-error" role="alert">{error}</p>}326 </section>327 );328}