clone

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

feat/fix-sync-enrollment-startup
lofi / package / starter / src / islands / AccountGate.tsx.txt
AccountGate.tsx.txt 15.7K
1import { useCallback, useEffect, useState } from "preact/hooks";2import {3 confirmPhraseAccess,4 createBackupPasskey,5 createRecoverablePasskeyBackup,6 describeStoreStatus,7 enableSyncBackup,8 getRuntimeDiagnostics,9 isAccountReplacementError,10 isAuthError,11 isRecoverablePasskeyError,12 isRecoveryError,13 isSyncOwnerError,14 readAccountSession,15 restoreFromPasskey,16 restoreFromRecoveryPhrase,17 revealRecoveryPhrase,18 runtimeRecreatedEvent,19 type Session,20 stopSyncBackup,21 subscribeRuntimeDiagnostics,22} from "@nzip/lofi";23import { encodeSharingIdentity } from "@nzip/lofi/access";24import { TicketEnrollForm } from "@nzip/lofi/preact";2526/**27 * Author-owned account example. lofi is local-first: the app already opened on a28 * device-local account with no sign-in. This island offers the *opt-in* upgrade —29 * back up and sync the account, and recover it elsewhere — using only the30 * public `@nzip/lofi` session primitives. Delete it if your app is local-only,31 * or restyle it freely; framework implementation remains package-owned.32 *33 * With no sync location — no compiled managed app and no enrolled ticket — it34 * offers the connect step instead: enroll an app-connect ticket (a password35 * manager autofills a saved one), and restore is available first, so a fresh36 * device can recover its identity before or after choosing where to sync.37 */3839type Busy =40 | "enable"41 | "stop"42 | "reveal"43 | "restore"44 | "passkey-restore"45 | "recoverable"46 | "protect";4748// Turns any thrown value into a sentence a person can act on.49function describe(error: unknown): string {50 if (isAuthError(error)) {51 switch (error.code) {52 case "cancelled":53 return "Passkey verification did not complete — your recovery phrase was not shown.";54 case "unsupported":55 return "This browser does not support passkeys.";56 default:57 return error.message;58 }59 }60 if (isRecoveryError(error)) return error.message;61 if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message;62 if (isSyncOwnerError(error)) return error.message;63 return error instanceof Error ? error.message : String(error);64}6566export default function AccountGate() {67 const [session, setSession] = useState<Session | null>(null);68 const [busy, setBusy] = useState<Busy | null>(null);69 const [error, setError] = useState<string | null>(null);70 const [phrase, setPhrase] = useState<string | null>(null);71 const [phraseInput, setPhraseInput] = useState("");72 // Backup happens in two steps: reveal the phrase first, then enable sync73 // only after the user confirms it is saved — enabling sync reloads the74 // document, which would wipe an unread phrase from the screen.75 const [pendingEnable, setPendingEnable] = useState(false);76 const [restoring, setRestoring] = useState(false);77 const [confirmReplacement, setConfirmReplacement] = useState(false);78 const sharingIdentity = session?.user_id ? encodeSharingIdentity(session.user_id) : null;79 // Set when a backup proceeded without a passkey guard (WebAuthn unavailable),80 // so the phrase block can say so honestly rather than imply a confirmation.81 const [unguarded, setUnguarded] = useState(false);8283 const [diagnostics, setDiagnostics] = useState(getRuntimeDiagnostics());8485 useEffect(() => {86 const refresh = () =>87 void readAccountSession().then(setSession, (cause) => setError(describe(cause)));88 refresh();89 // Electing sync, stopping it, or restoring an account recreates the90 // runtime; a snapshot read once on mount would keep rendering the old91 // account state over the new reality.92 globalThis.addEventListener(runtimeRecreatedEvent, refresh);93 return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);94 }, []);95 useEffect(() => subscribeRuntimeDiagnostics(() => setDiagnostics(getRuntimeDiagnostics())), []);9697 // Every account action funnels through here: one busy flag, one error line,98 // and any returned Session becomes the new snapshot.99 const run = useCallback(100 (kind: Busy, action: () => Promise<Session | void>) => {101 setBusy(kind);102 setError(null);103 void action().then(104 (result) => {105 if (result) setSession(result);106 },107 (cause) => setError(describe(cause)),108 ).finally(() => setBusy(null));109 },110 [],111 );112113 if (!session) return null;114115 const disabled = busy !== null;116117 // Restore works without a sync location: it replaces the local identity, and118 // synced data arrives once a location exists. Rendered in both the connect119 // and the back-up states.120 const restoreBlock = (121 <>122 <div class="account-actions">123 <button124 type="button"125 class="account-secondary"126 disabled={disabled || !confirmReplacement}127 onClick={() => {128 setRestoring(true);129 run("passkey-restore", () =>130 restoreFromPasskey({ confirmLocalReplacement: confirmReplacement }).then((next) => {131 setConfirmReplacement(false);132 return next;133 }));134 }}135 >136 {busy === "passkey-restore" ? "Restoring…" : "Use passkey"}137 </button>138 <button139 type="button"140 class="account-secondary"141 disabled={disabled || !confirmReplacement}142 onClick={() => {143 setError(null);144 setRestoring((value) =>145 !value146 );147 }}148 >149 {restoring ? "Cancel" : "Restore from recovery phrase"}150 </button>151 </div>152 <label class="account-note">153 <input154 type="checkbox"155 checked={confirmReplacement}156 disabled={disabled}157 onChange={(event) => setConfirmReplacement(event.currentTarget.checked)}158 />{" "}159 I understand restore replaces this device's current local account and unsynced data may be160 lost.161 </label>162 {restoring && (163 <div class="account-field">164 <label for="recovery-input">Recovery phrase</label>165 <textarea166 id="recovery-input"167 value={phraseInput}168 onInput={(event) => setPhraseInput(event.currentTarget.value)}169 placeholder="word one word two …"170 rows={3}171 disabled={disabled}172 />173 <p class="account-note">174 Restoring replaces this device's local account with the recovered one — any data created175 here that is not backed up will be discarded.176 </p>177 <button178 type="button"179 disabled={disabled || phraseInput.trim().length === 0}180 onClick={() =>181 run(182 "restore",183 () =>184 restoreFromRecoveryPhrase(phraseInput, {185 confirmLocalReplacement: confirmReplacement,186 }).then((next) => {187 setRestoring(false);188 setPhraseInput("");189 setConfirmReplacement(false);190 return next;191 }),192 )}193 >194 {busy === "restore" ? "Restoring…" : "Restore account"}195 </button>196 </div>197 )}198 </>199 );200201 // Sync on this device was elected by a different account: the runtime booted202 // with transport suppressed so neither store can merge into the other. The203 // two remediations are exactly the actions offered here — stop syncing204 // releases the election for the current account, restore adopts the owner.205 if (session.syncOwnerMismatch) {206 const owner = diagnostics.syncOwner.state === "mismatch"207 ? diagnostics.syncOwner.owner_user_id208 : null;209 return (210 <section class="account account-out" aria-labelledby="account-title">211 <header>212 <p class="eyebrow">Account</p>213 <h2 id="account-title">Sync paused — set up by a different account</h2>214 </header>215 <p>216 Sync on this device was set up by{" "}217 {owner ? <code>{owner}</code> : "a different account"}, so nothing connects — syncing now218 would merge two accounts&rsquo; data. Stop syncing to release this device for the current219 account, or restore the owning account to resume where it left off.220 </p>221 <div class="account-actions">222 <button223 type="button"224 disabled={disabled}225 onClick={() => run("stop", stopSyncBackup)}226 >227 {busy === "stop" ? "Stopping…" : "Stop syncing & release this device"}228 </button>229 </div>230 {restoreBlock}231 {error && <p class="account-error" role="alert">{error}</p>}232 </section>233 );234 }235236 // No sync location yet: offer the connect step, with restore available so a237 // fresh device recovers its identity before or after choosing where to sync.238 if (!session.syncAvailable) {239 return (240 <section class="account account-out" aria-labelledby="account-title">241 <header>242 <p class="eyebrow">Account</p>243 <h2 id="account-title">Connect a sync location</h2>244 </header>245 <p>246 You are working on a private, on-device account. To sync or recover it, connect a sync247 location: paste the app-connect ticket from your node — a password manager fills a saved248 one. Restoring an account works before or after connecting; synced data arrives once a249 location exists.250 </p>251 <TicketEnrollForm252 title="Enroll an app-connect ticket"253 onEnrolled={() => {254 void readAccountSession().then(setSession, (cause) => setError(describe(cause)));255 }}256 />257 {restoreBlock}258 {error && <p class="account-error" role="alert">{error}</p>}259 </section>260 );261 }262263 const phraseBlock = phrase && (264 <div class="account-phrase">265 <p class="account-note">266 Write these {phrase.split(" ").length}{" "}267 words down and keep them somewhere safe. Anyone with them can open this account; lose every268 copy and the account cannot be recovered.269 </p>270 {unguarded && (271 <p class="account-note">272 A passkey could not be created on this browser or origin, so this reveal was not confirmed273 with one. Save the phrase carefully.274 </p>275 )}276 <ol class="account-words" aria-label="Recovery phrase">277 {phrase.split(" ").map((word, index) => <li key={index}>{word}</li>)}278 </ol>279 </div>280 );281282 // A definite store problem is worth a line here, not only in the device283 // report: the account panel is where the user just acted, and each of these284 // states means writes are not replicating despite sync being on.285 const storeStatus = diagnostics.storeStatus;286 const storeProblem = storeStatus.state === "no_schema" ||287 storeStatus.state === "ticket_rejected" ||288 storeStatus.state === "store_unavailable"289 ? describeStoreStatus(storeStatus)290 : null;291292 if (session.backedUp) {293 return (294 <section class="account account-in" aria-labelledby="account-title">295 <header>296 <p class="eyebrow">Account</p>297 <h2 id="account-title">Backed up &amp; syncing</h2>298 </header>299 <p>300 This account replicates to your managed Jazz app and can be recovered with its phrase.301 {session.passkeyRecoverable302 ? " Its account-recovery passkey can restore the same identity where your passkey provider makes it available."303 : " Create an account-recovery passkey if this browser supports one."}304 </p>305 {sharingIdentity && (306 <p class="account-note">307 Share identity: <code data-sharing-identity>{sharingIdentity}</code>308 </p>309 )}310 {storeProblem && (311 <p class="account-note" role="alert">312 Sync location: {storeProblem}. See the device report for details.313 </p>314 )}315 <div class="account-actions">316 {!session.passkeyRecoverable && (317 <button318 type="button"319 disabled={disabled}320 onClick={() =>321 run("recoverable", async () => {322 await createRecoverablePasskeyBackup();323 return readAccountSession();324 })}325 >326 {busy === "recoverable" ? "Creating…" : "Create account-recovery passkey"}327 </button>328 )}329 <button330 type="button"331 disabled={disabled}332 onClick={() => {333 if (phrase) {334 setPhrase(null);335 return;336 }337 run("reveal", async () => {338 await confirmPhraseAccess();339 setPhrase(await revealRecoveryPhrase());340 });341 }}342 >343 {busy === "reveal" ? "Revealing…" : phrase ? "Hide phrase" : "Show recovery phrase"}344 </button>345 {!session.phraseGuarded && (346 <button347 type="button"348 class="account-secondary"349 disabled={disabled}350 onClick={() =>351 run("protect", async () => {352 setUnguarded(!(await createBackupPasskey()));353 return readAccountSession();354 })}355 >356 {busy === "protect" ? "Setting up…" : "Add local phrase-reveal guard"}357 </button>358 )}359 <button360 type="button"361 class="account-secondary"362 disabled={disabled}363 onClick={() => {364 setPhrase(null);365 run("stop", stopSyncBackup);366 }}367 >368 {busy === "stop" ? "Stopping…" : "Stop syncing"}369 </button>370 </div>371 {phraseBlock}372 {error && <p class="account-error" role="alert">{error}</p>}373 </section>374 );375 }376377 return (378 <section class="account account-out" aria-labelledby="account-title">379 <header>380 <p class="eyebrow">Account</p>381 <h2 id="account-title">Back up &amp; sync</h2>382 </header>383 <p>384 You are working on a private, on-device account — no sign-in, nothing sent anywhere. Back it385 up to sync across your devices and recover it if this one is lost. Your existing data comes386 along. A recoverable passkey is the quick return path; the recovery phrase remains the387 portable bearer-secret fallback.388 </p>389 <div class="account-actions">390 {!pendingEnable && (391 <button392 type="button"393 disabled={disabled}394 onClick={() =>395 run("enable", async () => {396 try {397 await createRecoverablePasskeyBackup();398 setUnguarded(false);399 } catch (cause) {400 if (!isRecoverablePasskeyError(cause) || cause.code !== "unsupported") {401 throw cause;402 }403 setUnguarded(true);404 }405 setPhrase(await revealRecoveryPhrase());406 setPendingEnable(true);407 })}408 >409 {busy === "enable" ? "Backing up…" : "Back up & enable sync"}410 </button>411 )}412 </div>413 {restoreBlock}414 {phraseBlock}415 {pendingEnable && (416 <div class="account-actions">417 <p class="account-note">418 Sync starts once you confirm the phrase is saved — the page reloads and the phrase will419 not be shown again automatically.420 </p>421 <button422 type="button"423 disabled={disabled}424 onClick={() => run("enable", enableSyncBackup)}425 >426 {busy === "enable" ? "Enabling sync…" : "I saved my phrase — enable sync"}427 </button>428 </div>429 )}430 {error && <p class="account-error" role="alert">{error}</p>}431 </section>432 );433}