An encrypted personal cloud built on the AT Protocol.

Restructure web frontend routes and split auth store state

Move device management routes from flat cabinet.devices.* to nested
devices/ directory with dedicated layout. Split auth store into
independent session and identity state axes. Extract device flow
views into focused components (CheckingView, FreshAccountView,
ConflictView, ReadyView, RecoverIdentityView). Add loading state
management via app store, minimum display duration hook, and
destructive confirmation component. Fix review findings: remove
artificial delay from store, add error logging to PDS lookups,
fix polling leak in pair.accept, correct immer usage in
oauth-callback, and restore production URL comment in .envrc.

sans-self.org 1f332df4 82bf779e

Waiting for spindle ...
+1919 -1191
+3 -1
.crosslink/hook-config.json
··· 17 17 "tsc", 18 18 "node ", 19 19 "python ", 20 + "python3 ", 20 21 "ls", 21 22 "dir", 22 23 "pwd", 23 - "echo" 24 + "echo", 25 + "cd " 24 26 ], 25 27 "blocked_git_commands": [ 26 28 "git push",
+2 -2
.envrc
··· 1 1 # Frontend URL for CLI OAuth callback redirect 2 - # Production: https://app.opake.app/oauth/cli-callback 3 - export OPAKE_FRONTEND_URL=http://localhost:5173/oauth/cli-callback 2 + # Production: https://opake.app/devices/cli-callback 3 + export OPAKE_FRONTEND_URL=http://localhost:5173
+1
.gitignore
··· 11 11 .ripsed 12 12 .opencode/ 13 13 driver-key.pub 14 + .vscode/ 14 15 15 16 # === Crosslink managed (do not edit between markers) === 16 17 # .crosslink/ — machine-local state (never commit)
+24
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add ESLint, Prettier, and Immer to web frontend [#207](https://issues.opake.app/issues/207.html) 16 + - Refactor auth store into session + identity state, non-blocking boot [#209](https://issues.opake.app/issues/209.html) 17 + - Add destructive action confirmation component with ghost-text typing [#218](https://issues.opake.app/issues/218.html) 18 + - Add loading state tracking to device pairing pages [#225](https://issues.opake.app/issues/225.html) 19 + - Add ESLint, Prettier, and Immer to web frontend [#207](https://issues.opake.app/issues/207.html) 20 + - Refactor auth store into session + identity state, non-blocking boot [#209](https://issues.opake.app/issues/209.html) 21 + - Add destructive action confirmation component with ghost-text typing [#218](https://issues.opake.app/issues/218.html) 22 + - Add loading state tracking to device pairing pages [#225](https://issues.opake.app/issues/225.html) 23 + - Move device pairing into devices route folder as pair.request and pair.accept [#220](https://issues.opake.app/issues/220.html) 15 24 - Add --dir flag to mkdir for nested directory creation [#198](https://issues.opake.app/issues/198.html) 16 25 - Add purge command to delete all Opake data from PDS [#196](https://issues.opake.app/issues/196.html) 17 26 - Add metadata CLI command for rename, tag, and description management [#190](https://issues.opake.app/issues/190.html) ··· 64 73 - Update login command to read password from stdin [#112](https://issues.opake.app/issues/112.html) 65 74 66 75 ### Fixed 76 + - Fix application startup to check account status [#194](https://issues.opake.app/issues/194.html) 77 + - Fix web login on new account creating publicKey record without $bytes [#195](https://issues.opake.app/issues/195.html) 78 + - Fix Identity type casing mismatch across WASM boundary [#203](https://issues.opake.app/issues/203.html) 79 + - Fix application startup to check account status [#194](https://issues.opake.app/issues/194.html) 80 + - Fix web login on new account creating publicKey record without $bytes [#195](https://issues.opake.app/issues/195.html) 81 + - Fix session restore on page reload by deferring router until boot completes [#210](https://issues.opake.app/issues/210.html) 82 + - Fix Identity type casing mismatch across WASM boundary [#203](https://issues.opake.app/issues/203.html) 83 + - Fix OpakeLogo loading animation not completing at least one full cycle [#226](https://issues.opake.app/issues/226.html) 84 + - Fix review findings from web frontend restructure [#227](https://issues.opake.app/issues/227.html) 85 + - Fix session restore on page reload by deferring router until boot completes [#210](https://issues.opake.app/issues/210.html) 86 + - Fix OpakeLogo loading animation not completing at least one full cycle [#226](https://issues.opake.app/issues/226.html) 87 + - Fix review findings from web frontend restructure [#227](https://issues.opake.app/issues/227.html) 88 + - Fix identity conflict false positive caused by AT Protocol bytes encoding [#224](https://issues.opake.app/issues/224.html) 67 89 - Fix rm -yr / failing with empty path error on root directory [#202](https://issues.opake.app/issues/202.html) 68 90 - Fix mkdir creating duplicate directories with the same name [#201](https://issues.opake.app/issues/201.html) 69 91 - Fix token refresh not triggering on HTTP 401 ExpiredToken responses [#197](https://issues.opake.app/issues/197.html) ··· 73 95 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 74 96 75 97 ### Changed 98 + - Add cn() utility (clsx + tailwind-merge) [#208](https://issues.opake.app/issues/208.html) 99 + - Add cn() utility (clsx + tailwind-merge) [#208](https://issues.opake.app/issues/208.html) 76 100 - Add metadata CLI command with tag and description subcommands [#190](https://issues.opake.app/issues/190.html) 77 101 - Encrypt directory metadata (add encryption envelope to directory records) [#189](https://issues.opake.app/issues/189.html) 78 102 - Encrypt keyring and grant metadata [#188](https://issues.opake.app/issues/188.html)
+4 -4
crates/opake-cli/src/oauth.rs
··· 113 113 let frontend_callback_url = if no_redirect { 114 114 None 115 115 } else { 116 - Some( 117 - std::env::var("OPAKE_FRONTEND_URL") 118 - .unwrap_or_else(|_| "https://app.opake.app/oauth/cli-callback".to_string()), 119 - ) 116 + let prefix = 117 + std::env::var("OPAKE_FRONTEND_URL").unwrap_or_else(|_| "https://opake.app".to_string()); 118 + 119 + Some(format!("{}/devices/cli-callback", prefix)) 120 120 }; 121 121 122 122 // Step 6: Wait for the callback (PAR request_uri expires)
+4 -2
web/.prettierrc
··· 1 1 { 2 - "semi": false, 2 + "semi": true, 3 3 "singleQuote": false, 4 4 "printWidth": 100, 5 5 "trailingComma": "all", 6 - "plugins": ["prettier-plugin-tailwindcss"] 6 + "plugins": [ 7 + "prettier-plugin-tailwindcss" 8 + ] 7 9 }
+14
web/CHANGELOG.md
··· 1 + # Changelog 2 + 3 + All notable changes to this project will be documented in this file. 4 + 5 + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 + 7 + ## [Unreleased] 8 + 9 + ### Added 10 + - Extract devices index views into separate components (#1) 11 + 12 + ### Fixed 13 + 14 + ### Changed
+6
web/bun.lock
··· 7 7 "dependencies": { 8 8 "@phosphor-icons/react": "^2.1.10", 9 9 "@tanstack/react-router": "^1.163.3", 10 + "clsx": "^2.1.1", 10 11 "comlink": "^4.4.2", 11 12 "dexie": "^4.3.0", 12 13 "immer": "^11.1.4", 13 14 "react": "^19.2.4", 14 15 "react-dom": "^19.2.4", 16 + "tailwind-merge": "^3.5.0", 15 17 "zustand": "^5.0.11", 16 18 }, 17 19 "devDependencies": { ··· 422 424 "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], 423 425 424 426 "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], 427 + 428 + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 425 429 426 430 "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], 427 431 ··· 880 884 "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], 881 885 882 886 "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], 887 + 888 + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], 883 889 884 890 "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], 885 891
+2
web/package.json
··· 19 19 "dependencies": { 20 20 "@phosphor-icons/react": "^2.1.10", 21 21 "@tanstack/react-router": "^1.163.3", 22 + "clsx": "^2.1.1", 22 23 "comlink": "^4.4.2", 23 24 "dexie": "^4.3.0", 24 25 "immer": "^11.1.4", 25 26 "react": "^19.2.4", 26 27 "react-dom": "^19.2.4", 28 + "tailwind-merge": "^3.5.0", 27 29 "zustand": "^5.0.11" 28 30 }, 29 31 "devDependencies": {
+131
web/src/components/DestructiveConfirmation.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + import { cn } from "@/lib/cn"; 3 + 4 + interface DestructiveConfirmationProps { 5 + readonly phrase: string; 6 + readonly onConfirm: () => void; 7 + readonly className?: string; 8 + } 9 + 10 + function blockClipboardAndDrag(e: React.ClipboardEvent | React.DragEvent) { 11 + e.preventDefault(); 12 + } 13 + 14 + export function DestructiveConfirmation({ 15 + phrase, 16 + onConfirm, 17 + className, 18 + }: DestructiveConfirmationProps) { 19 + const [typed, setTyped] = useState(""); 20 + const inputRef = useRef<HTMLInputElement>(null); 21 + const confirmedRef = useRef(false); 22 + 23 + const hasError = typed.length > 0 && !phrase.startsWith(typed); 24 + const errorIndex = hasError ? typed.split("").findIndex((char, i) => char !== phrase[i]) : -1; 25 + 26 + useEffect(() => { 27 + inputRef.current?.focus(); 28 + }, []); 29 + 30 + const handleChange = useCallback( 31 + (e: React.ChangeEvent<HTMLInputElement>) => { 32 + const value = e.target.value; 33 + if (value.length <= phrase.length) { 34 + setTyped(value); 35 + } 36 + }, 37 + [phrase], 38 + ); 39 + 40 + useEffect(() => { 41 + if (typed === phrase && !confirmedRef.current) { 42 + confirmedRef.current = true; 43 + onConfirm(); 44 + } 45 + }, [typed, phrase, onConfirm]); 46 + 47 + return ( 48 + <div 49 + className={cn( 50 + "card card-bordered bg-base-100 flex flex-col items-center gap-3 p-5", 51 + className, 52 + )} 53 + > 54 + <div 55 + className="relative cursor-text text-left" 56 + onClick={() => inputRef.current?.focus()} 57 + role="presentation" 58 + > 59 + {/* Ghost text — the full phrase, unselectable */} 60 + <div 61 + aria-hidden="true" 62 + className="text-base-content/20 pointer-events-none font-mono text-sm tracking-wide select-none" 63 + > 64 + {phrase.split("").map((char, i) => ( 65 + <span 66 + key={i} 67 + className={cn( 68 + i < typed.length && "invisible", 69 + i === typed.length && "text-base-content/35", 70 + )} 71 + > 72 + {char} 73 + </span> 74 + ))} 75 + </div> 76 + 77 + {/* Typed overlay — sits on top, character-colored */} 78 + <div className="pointer-events-none absolute inset-0 font-mono text-sm tracking-wide"> 79 + {typed.split("").map((char, i) => { 80 + const correct = char === phrase[i]; 81 + return ( 82 + <span 83 + key={i} 84 + className={cn( 85 + correct && "text-base-content", 86 + !correct && "text-error bg-error/10 rounded-sm", 87 + )} 88 + > 89 + {char} 90 + </span> 91 + ); 92 + })} 93 + {/* Blinking caret */} 94 + {typed !== phrase && ( 95 + <span className="border-primary/60 inline-block h-[1.1em] w-0 translate-y-[0.15em] animate-pulse border-l-2" /> 96 + )} 97 + </div> 98 + 99 + {/* Invisible input for actual typing */} 100 + <input 101 + ref={inputRef} 102 + type="text" 103 + value={typed} 104 + onChange={handleChange} 105 + onPaste={blockClipboardAndDrag} 106 + onCopy={blockClipboardAndDrag} 107 + onCut={blockClipboardAndDrag} 108 + onDrop={blockClipboardAndDrag} 109 + autoComplete="off" 110 + autoCorrect="off" 111 + autoCapitalize="off" 112 + spellCheck={false} 113 + aria-label={`Type "${phrase}" to confirm`} 114 + className="absolute inset-0 cursor-text caret-transparent opacity-0" 115 + /> 116 + </div> 117 + 118 + <p 119 + className={cn( 120 + "text-caption", 121 + hasError && errorIndex >= 0 ? "text-error" : "text-base-content/40", 122 + )} 123 + role="alert" 124 + > 125 + {hasError && errorIndex >= 0 126 + ? `Expected "${phrase[errorIndex]}" — try again from there` 127 + : "Type the phrase above to confirm"} 128 + </p> 129 + </div> 130 + ); 131 + }
+82 -9
web/src/components/OpakeLogo.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + 1 3 const SIZES = { 2 4 sm: { square: 16, wrap: 22, text: "text-[0.9rem]" }, 3 5 md: { square: 22, wrap: 28, text: "text-[1.1rem]" }, 4 6 lg: { square: 30, wrap: 36, text: "text-[1.5rem]" }, 5 - } as const 7 + xl: { square: 40, wrap: 48, text: "text-[2rem]" }, 8 + "2xl": { square: 54, wrap: 64, text: "text-[2.75rem]" }, 9 + } as const; 10 + type LogoSize = keyof typeof SIZES; 11 + 12 + type Props = Readonly<{ 13 + size?: LogoSize; 14 + loading?: boolean; 15 + }>; 16 + 17 + export function OpakeLogo({ size = "md", loading = false }: Props) { 18 + const { square, wrap, text } = SIZES[size]; 19 + const SOLID_BG = "oklch(0.58 0.095 75 / 0.7)"; 20 + const GHOST_BG = "oklch(0.58 0.095 75 / 0.2)"; 21 + const GHOST_BORDER = "1.5px solid oklch(0.58 0.095 75 / 0.45)"; 22 + const NO_BORDER = "0 solid transparent"; 23 + 24 + const square1Style = { 25 + width: square, 26 + height: square, 27 + "--space": `${wrap - square}px`, 28 + "--sq-dir": 1, 29 + "--sq-from": SOLID_BG, 30 + "--sq-to": GHOST_BG, 31 + "--sq-border-from": NO_BORDER, 32 + "--sq-border-to": GHOST_BORDER, 33 + background: SOLID_BG, 34 + border: NO_BORDER, 35 + } as React.CSSProperties; 36 + 37 + const square2Style = { 38 + width: square, 39 + height: square, 40 + "--space": `${wrap - square}px`, 41 + "--sq-dir": -1, 42 + "--sq-from": GHOST_BG, 43 + "--sq-to": SOLID_BG, 44 + "--sq-border-from": GHOST_BORDER, 45 + "--sq-border-to": NO_BORDER, 46 + background: GHOST_BG, 47 + border: GHOST_BORDER, 48 + } as React.CSSProperties; 49 + 50 + const square1Ref = useRef<HTMLDivElement>(null); 51 + const square2Ref = useRef<HTMLDivElement>(null); 52 + 53 + useEffect(() => { 54 + const els = [square1Ref.current, square2Ref.current].filter( 55 + (el): el is HTMLDivElement => el !== null, 56 + ); 57 + if (els.length === 0) return; 58 + 59 + if (loading) { 60 + els.forEach((el) => el.classList.add("animate-logo-square")); 61 + return; 62 + } 63 + 64 + // loading stopped — let the current cycle finish, then remove 65 + const handleIteration = (e: AnimationEvent) => { 66 + const el = e.currentTarget as HTMLElement; 67 + el.classList.remove("animate-logo-square"); 68 + el.removeEventListener("animationiteration", handleIteration as EventListener); 69 + }; 6 70 7 - type LogoSize = keyof typeof SIZES 71 + els.forEach((el) => { 72 + if (!el.classList.contains("animate-logo-square")) return; 73 + el.addEventListener("animationiteration", handleIteration as EventListener); 74 + }); 8 75 9 - export function OpakeLogo({ size = "md" }: Readonly<{ size?: LogoSize }>) { 10 - const { square, wrap, text } = SIZES[size] 76 + return () => { 77 + els.forEach((el) => 78 + el.removeEventListener("animationiteration", handleIteration as EventListener), 79 + ); 80 + }; 81 + }, [loading]); 11 82 12 83 return ( 13 84 <div className="flex items-center gap-2.5"> 14 85 <div className="relative shrink-0" style={{ width: wrap, height: wrap }}> 15 86 <div 16 - className="bg-primary/70 absolute top-0 left-0 rounded-[3px]" 17 - style={{ width: square, height: square }} 87 + className="absolute top-0 left-0 rounded-sm" 88 + ref={square1Ref} 89 + style={square1Style} 18 90 /> 19 91 <div 20 - className="border-primary/45 bg-primary/20 absolute right-0 bottom-0 rounded-[3px] border-[1.5px]" 21 - style={{ width: square, height: square }} 92 + className="absolute right-0 bottom-0 rounded-sm" 93 + ref={square2Ref} 94 + style={square2Style} 22 95 /> 23 96 </div> 24 97 <span className={`font-display text-base-content font-medium tracking-[0.05em] ${text}`}> 25 98 Opake 26 99 </span> 27 100 </div> 28 - ) 101 + ); 29 102 }
+10
web/src/components/devices/CheckingView.tsx
··· 1 + import { OpakeLogo } from "@/components/OpakeLogo"; 2 + 3 + export function CheckingView() { 4 + return ( 5 + <div className="flex flex-col items-center gap-4"> 6 + <OpakeLogo loading size="2xl" /> 7 + <p className="text-base-content/60 text-sm">Setting things up…</p> 8 + </div> 9 + ); 10 + }
+53
web/src/components/devices/ChoiceButton.tsx
··· 1 + import { cn } from "@/lib/cn"; 2 + import type { Icon } from "@phosphor-icons/react"; 3 + import { Link } from "@tanstack/react-router"; 4 + import type { ComponentProps } from "react"; 5 + 6 + const cardClass = cn( 7 + "card card-bordered bg-base-100 hover:border-primary/40 cursor-pointer p-5 text-left transition-colors w-46", 8 + "disabled:opacity-50 disabled:cursor-not-allowed", 9 + ); 10 + type CommonProps = Readonly<{ 11 + title: string; 12 + description: string; 13 + icon: Icon; 14 + }>; 15 + 16 + type LinkChoiceProps = CommonProps & { as: "Link" } & ComponentProps<typeof Link>; 17 + type ButtonChoiceProps = CommonProps & { 18 + as: "Button"; 19 + } & React.ButtonHTMLAttributes<HTMLButtonElement>; 20 + 21 + type Props = LinkChoiceProps | ButtonChoiceProps; 22 + 23 + function CardContent({ icon: IconComponent, title, description }: CommonProps) { 24 + return ( 25 + <> 26 + <IconComponent size={24} className="text-primary mb-3" aria-hidden="true" /> 27 + <h2 className="text-base-content font-medium">{title}</h2> 28 + <p className="text-caption text-base-content/60 mt-1">{description}</p> 29 + </> 30 + ); 31 + } 32 + 33 + export function ChoiceButton(props: Props) { 34 + const { as: as_, title, description, icon, className, ...rest } = props; 35 + const common = { title, description, icon }; 36 + 37 + if (as_ === "Link") { 38 + return ( 39 + <Link className={cn(cardClass, className)} {...(rest as ComponentProps<typeof Link>)}> 40 + <CardContent {...common} /> 41 + </Link> 42 + ); 43 + } 44 + 45 + return ( 46 + <button 47 + className={cn(cardClass, className)} 48 + {...(rest as React.ButtonHTMLAttributes<HTMLButtonElement>)} 49 + > 50 + <CardContent {...common} /> 51 + </button> 52 + ); 53 + }
+69
web/src/components/devices/ConflictView.tsx
··· 1 + import { useState } from "react"; 2 + import { useAuthStore } from "@/stores/auth"; 3 + import { ArrowsLeftRightIcon, KeyIcon, WarningIcon, AmbulanceIcon } from "@phosphor-icons/react"; 4 + import { DestructiveConfirmation } from "@/components/DestructiveConfirmation"; 5 + import { ChoiceButton } from "./ChoiceButton"; 6 + import { PageHeader } from "./PageHeader"; 7 + 8 + export function ConflictView() { 9 + const [confirming, setConfirming] = useState(false); 10 + 11 + if (confirming) { 12 + return ( 13 + <div className="flex flex-col items-center gap-6 text-center"> 14 + <PageHeader 15 + icon={WarningIcon} 16 + iconClassName="text-error" 17 + title="This will destroy your current key" 18 + description="Files encrypted with the old key won't be accessible." 19 + /> 20 + <DestructiveConfirmation 21 + phrase="This will make my old data unusable and I am okay with that" 22 + onConfirm={() => void useAuthStore.getState().generateAndPublishIdentity()} 23 + /> 24 + <button 25 + onClick={() => setConfirming(false)} 26 + className="text-base-content/50 hover:text-base-content/70 cursor-pointer text-sm" 27 + > 28 + Go back 29 + </button> 30 + </div> 31 + ); 32 + } 33 + 34 + return ( 35 + <div className="flex flex-col items-center gap-6 text-center"> 36 + <PageHeader 37 + icon={WarningIcon} 38 + title="This device is out of sync" 39 + description="The key on this device doesn't match the one your files were encrypted with. Pick how you'd like to fix it." 40 + /> 41 + 42 + <div className="flex w-full flex-wrap justify-center-safe gap-4"> 43 + <ChoiceButton 44 + as="Button" 45 + onClick={() => setConfirming(true)} 46 + icon={KeyIcon} 47 + title="Start fresh" 48 + description="Create a new key. Files encrypted with the old key won't be accessible." 49 + /> 50 + 51 + <ChoiceButton 52 + as="Link" 53 + to="/devices/pair/request" 54 + icon={ArrowsLeftRightIcon} 55 + title="Sync from another device" 56 + description="Use a device that can already open your files to copy the key here." 57 + /> 58 + 59 + <ChoiceButton 60 + as="Button" 61 + disabled 62 + icon={AmbulanceIcon} 63 + title="Use your recovery phrase" 64 + description="Enter the 24 words you saved when you first set up. (coming soon)" 65 + /> 66 + </div> 67 + </div> 68 + ); 69 + }
+22
web/src/components/devices/FreshAccountView.tsx
··· 1 + import { useAuthStore } from "@/stores/auth"; 2 + import { KeyIcon } from "@phosphor-icons/react"; 3 + import { ChoiceButton } from "./ChoiceButton"; 4 + import { PageHeader } from "./PageHeader"; 5 + 6 + export function FreshAccountView() { 7 + return ( 8 + <div className="flex flex-col items-center gap-6 text-center"> 9 + <PageHeader 10 + title="Welcome to Opake" 11 + description="To keep your files private, Opake needs to create an encryption key for this device." 12 + /> 13 + <ChoiceButton 14 + as="Button" 15 + onClick={() => void useAuthStore.getState().generateAndPublishIdentity()} 16 + icon={KeyIcon} 17 + title="Create my key" 18 + description="This only takes a moment." 19 + /> 20 + </div> 21 + ); 22 + }
+22
web/src/components/devices/PageHeader.tsx
··· 1 + import type { Icon } from "@phosphor-icons/react"; 2 + 3 + interface PageHeaderProps { 4 + readonly title: string; 5 + readonly description?: string; 6 + readonly icon?: Icon; 7 + readonly iconClassName?: string; 8 + } 9 + 10 + export function PageHeader({ title, description, icon: IconComponent, iconClassName }: PageHeaderProps) { 11 + return ( 12 + <> 13 + {IconComponent && ( 14 + <IconComponent size={48} className={iconClassName ?? "text-warning"} weight="fill" /> 15 + )} 16 + <div className="flex flex-col gap-2"> 17 + <h1 className="text-base-content text-2xl font-semibold">{title}</h1> 18 + {description && <p className="text-base-content/60 text-sm">{description}</p>} 19 + </div> 20 + </> 21 + ); 22 + }
+36
web/src/components/devices/ReadyView.tsx
··· 1 + import { useAuthStore } from "@/stores/auth"; 2 + import { ArrowsLeftRightIcon } from "@phosphor-icons/react"; 3 + import { Link } from "@tanstack/react-router"; 4 + import { ChoiceButton } from "./ChoiceButton"; 5 + import { PageHeader } from "./PageHeader"; 6 + 7 + export function ReadyView() { 8 + return ( 9 + <div className="flex flex-col items-center gap-6 text-center"> 10 + <PageHeader 11 + title="You're all set" 12 + description="This device can encrypt and decrypt your files. You can also use it to set up other devices." 13 + /> 14 + 15 + <ChoiceButton 16 + as="Link" 17 + to="/devices/pair/accept" 18 + icon={ArrowsLeftRightIcon} 19 + title="Set up another device" 20 + description="Share your key with a phone, tablet, or another computer." 21 + /> 22 + 23 + <div className="flex flex-col items-center gap-2"> 24 + <Link to="/cabinet" className="text-base-content/50 hover:text-base-content/70 text-sm"> 25 + Back to cabinet 26 + </Link> 27 + <button 28 + onClick={() => void useAuthStore.getState().logout()} 29 + className="text-error/60 hover:text-error/80 text-sm" 30 + > 31 + Log out 32 + </button> 33 + </div> 34 + </div> 35 + ); 36 + }
+32
web/src/components/devices/RecoverIdentityView.tsx
··· 1 + import { ArrowsLeftRightIcon, AmbulanceIcon } from "@phosphor-icons/react"; 2 + import { ChoiceButton } from "./ChoiceButton"; 3 + import { PageHeader } from "./PageHeader"; 4 + 5 + export function RecoverIdentityView() { 6 + return ( 7 + <div className="flex flex-col items-center gap-6 text-center"> 8 + <PageHeader 9 + title="Welcome back" 10 + description="You're already set up on another device. Bring your key here to access your files." 11 + /> 12 + 13 + <div className="flex w-full flex-wrap justify-center-safe gap-4"> 14 + <ChoiceButton 15 + as="Link" 16 + to="/devices/pair/request" 17 + icon={ArrowsLeftRightIcon} 18 + title="Copy from another device" 19 + description="Open Opake on a device you're already using and approve the transfer." 20 + /> 21 + 22 + <ChoiceButton 23 + as="Button" 24 + disabled 25 + icon={AmbulanceIcon} 26 + title="Use your recovery phrase" 27 + description="[coming v0.2.0] Enter the 24 words you saved when you first set up." 28 + /> 29 + </div> 30 + </div> 31 + ); 32 + }
+65 -35
web/src/index.css
··· 8 8 default: true; 9 9 10 10 /* Surfaces */ 11 - --color-base-100: oklch(0.985 0.005 85); /* #FDFAF5 — panel bg */ 12 - --color-base-200: oklch(0.957 0.012 85); /* #F5F1E8 — sidebar bg */ 13 - --color-base-300: oklch(0.930 0.015 85); /* #EDE8DC — page bg */ 11 + --color-base-100: oklch(0.985 0.005 85); /* #FDFAF5 — panel bg */ 12 + --color-base-200: oklch(0.957 0.012 85); /* #F5F1E8 — sidebar bg */ 13 + --color-base-300: oklch(0.93 0.015 85); /* #EDE8DC — page bg */ 14 14 --color-base-content: oklch(0.155 0.035 70); /* #1C1408 — primary text */ 15 15 16 16 /* Accent gold */ 17 - --color-primary: oklch(0.580 0.095 75); /* #9A7840 */ 17 + --color-primary: oklch(0.58 0.095 75); /* #9A7840 */ 18 18 --color-primary-content: oklch(0.976 0.008 85); /* #FAF7F1 */ 19 19 20 20 /* Muted text / secondary */ 21 - --color-secondary: oklch(0.390 0.055 65); /* #5C4A2E */ 21 + --color-secondary: oklch(0.39 0.055 65); /* #5C4A2E */ 22 22 --color-secondary-content: oklch(0.976 0.008 85); /* #FAF7F1 */ 23 23 24 24 /* Accent light bg */ 25 - --color-accent: oklch(0.940 0.035 85); /* #F5E9D0 */ 26 - --color-accent-content: oklch(0.490 0.080 70); /* #7D6230 */ 25 + --color-accent: oklch(0.94 0.035 85); /* #F5E9D0 */ 26 + --color-accent-content: oklch(0.49 0.08 70); /* #7D6230 */ 27 27 28 28 /* Dark UI */ 29 - --color-neutral: oklch(0.155 0.035 70); /* #1C1408 */ 29 + --color-neutral: oklch(0.155 0.035 70); /* #1C1408 */ 30 30 --color-neutral-content: oklch(0.976 0.008 85); /* #FAF7F1 */ 31 31 32 32 /* Semantic */ 33 - --color-success: oklch(0.530 0.065 145); /* #5C7A54 — shared/sage */ 33 + --color-success: oklch(0.53 0.065 145); /* #5C7A54 — shared/sage */ 34 34 --color-success-content: oklch(0.976 0.008 85); 35 - --color-warning: oklch(0.680 0.135 75); /* #C4952A — gold star */ 35 + --color-warning: oklch(0.68 0.135 75); /* #C4952A — gold star */ 36 36 --color-warning-content: oklch(0.155 0.035 70); 37 - --color-error: oklch(0.450 0.110 25); /* #A04840 — destructive */ 37 + --color-error: oklch(0.45 0.11 25); /* #A04840 — destructive */ 38 38 --color-error-content: oklch(0.976 0.008 85); 39 - --color-info: oklch(0.580 0.095 75); 39 + --color-info: oklch(0.58 0.095 75); 40 40 --color-info-content: oklch(0.976 0.008 85); 41 41 42 42 /* Shape */ ··· 55 55 --font-sans: "Inter", sans-serif; 56 56 57 57 /* Text scale */ 58 - --text-ui: 0.8125rem; /* 13px — primary UI text */ 59 - --text-caption: 0.6875rem; /* 11px — meta/caption */ 60 - --text-label: 0.625rem; /* 10px — small labels */ 61 - --text-micro: 0.5625rem; /* 9px — tiny text */ 58 + --text-ui: 0.8125rem; /* 13px — primary UI text */ 59 + --text-caption: 0.6875rem; /* 11px — meta/caption */ 60 + --text-label: 0.625rem; /* 10px — small labels */ 61 + --text-micro: 0.5625rem; /* 9px — tiny text */ 62 62 63 63 /* Custom colours */ 64 - --color-border-accent: oklch(0.790 0.060 80); /* #D4BC96 */ 65 - --color-text-faint: oklch(0.760 0.035 75); /* #C4B09A */ 66 - --color-text-muted: oklch(0.620 0.040 70); /* #9A8768 */ 67 - --color-bg-hover: oklch(0.580 0.095 75 / 0.055); /* gold hover */ 68 - --color-bg-ghost-1: oklch(0.940 0.025 80); /* #F5EDDB */ 69 - --color-bg-ghost-2: oklch(0.965 0.015 80); /* #FAF5EC */ 70 - --color-bg-sage: oklch(0.940 0.020 140); /* #EEF2E8 */ 71 - --color-bg-stone: oklch(0.950 0.010 80); /* #F4F1EC */ 64 + --color-border-accent: oklch(0.79 0.06 80); /* #D4BC96 */ 65 + --color-text-faint: oklch(0.76 0.035 75); /* #C4B09A */ 66 + --color-text-muted: oklch(0.62 0.04 70); /* #9A8768 */ 67 + --color-bg-hover: oklch(0.58 0.095 75 / 0.055); /* gold hover */ 68 + --color-bg-ghost-1: oklch(0.94 0.025 80); /* #F5EDDB */ 69 + --color-bg-ghost-2: oklch(0.965 0.015 80); /* #FAF5EC */ 70 + --color-bg-sage: oklch(0.94 0.02 140); /* #EEF2E8 */ 71 + --color-bg-stone: oklch(0.95 0.01 80); /* #F4F1EC */ 72 72 73 73 /* File-type icon colours */ 74 - --color-file-doc: #6676A8; 75 - --color-file-doc-bg: #EEF0F8; 76 - --color-file-sheet: #5C8A5C; 77 - --color-file-sheet-bg: #EEF4EE; 78 - --color-file-pdf: #A05040; 79 - --color-file-pdf-bg: #F5EEEC; 80 - --color-file-note: #8A6A30; 81 - --color-file-code: #7A6A98; 82 - --color-file-code-bg: #F0EEF5; 74 + --color-file-doc: #6676a8; 75 + --color-file-doc-bg: #eef0f8; 76 + --color-file-sheet: #5c8a5c; 77 + --color-file-sheet-bg: #eef4ee; 78 + --color-file-pdf: #a05040; 79 + --color-file-pdf-bg: #f5eeec; 80 + --color-file-note: #8a6a30; 81 + --color-file-code: #7a6a98; 82 + --color-file-code-bg: #f0eef5; 83 83 84 84 /* Elevation */ 85 85 --shadow-panel-sm: 0 1px 8px oklch(0.35 0.05 60 / 0.07); 86 86 --shadow-panel-md: 0 2px 16px oklch(0.35 0.05 60 / 0.09); 87 87 --shadow-panel-lg: 0 6px 32px oklch(0.35 0.05 60 / 0.12); 88 + 89 + /* Keyframes */ 90 + --animate-logo-square: logo-square 2s linear infinite; 91 + @keyframes logo-square { 92 + 0%, 93 + 100% { 94 + transform: translate(0, 0); 95 + background: var(--sq-from); 96 + border: var(--sq-border-from); 97 + } 98 + 25% { 99 + transform: translate(calc(var(--sq-dir) * var(--space)), 0); 100 + } 101 + 50% { 102 + transform: translate(calc(var(--sq-dir) * var(--space)), calc(var(--sq-dir) * var(--space))); 103 + background: var(--sq-to); 104 + border: var(--sq-border-to); 105 + } 106 + 75% { 107 + transform: translate(0, calc(var(--sq-dir) * var(--space))); 108 + } 109 + } 110 + 111 + --animate-hover-lift: hover-lift 0.2s ease-out forwards; 112 + @keyframes hover-lift { 113 + to { 114 + transform: translateY(-2px); 115 + box-shadow: var(--shadow-panel-sm); 116 + } 117 + } 88 118 } 89 119 90 120 /* ─── Utilities ──────────────────────────────────────────────────────────── */ ··· 94 124 0deg, 95 125 transparent, 96 126 transparent 13px, 97 - oklch(0.580 0.095 75 / 0.04) 13px, 98 - oklch(0.580 0.095 75 / 0.04) 14px 127 + oklch(0.58 0.095 75 / 0.04) 13px, 128 + oklch(0.58 0.095 75 / 0.04) 14px 99 129 ); 100 130 } 101 131
+4
web/src/lib/cn.ts
··· 1 + import { clsx, type ClassValue } from "clsx"; 2 + import { twMerge } from "tailwind-merge"; 3 + 4 + export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
+1 -1
web/src/lib/oauth.ts
··· 128 128 } 129 129 130 130 export function buildRedirectUri(): string { 131 - return `${window.location.origin}/oauth/callback` 131 + return `${window.location.origin}/devices/oauth-callback` 132 132 } 133 133 134 134 // ---------------------------------------------------------------------------
+85 -77
web/src/lib/pairing.ts
··· 1 1 // Device pairing XRPC orchestration. 2 2 // Consumes authenticatedXrpc from api.ts and crypto worker functions. 3 3 4 - import type { Remote } from "comlink" 5 - import type { CryptoApi } from "@/workers/crypto.worker" 6 - import type { WrappedKey, AtBytes } from "@/lib/crypto-types" 7 - import type { Identity, Session } from "@/lib/storage-types" 8 - import { authenticatedXrpc } from "@/lib/api" 4 + import type { Remote } from "comlink"; 5 + import type { CryptoApi } from "@/workers/crypto.worker"; 6 + import type { WrappedKey, AtBytes } from "@/lib/crypto-types"; 7 + import type { Identity, Session } from "@/lib/storage-types"; 8 + import { authenticatedXrpc } from "@/lib/api"; 9 9 import { 10 10 uint8ArrayToBase64, 11 11 base64ToUint8Array, 12 12 formatFingerprint, 13 13 rkeyFromUri, 14 - } from "@/lib/encoding" 14 + } from "@/lib/encoding"; 15 15 16 16 // --------------------------------------------------------------------------- 17 17 // Types 18 18 // --------------------------------------------------------------------------- 19 19 20 20 export interface PendingPairRequest { 21 - uri: string 22 - fingerprint: string 23 - createdAt: string 24 - ephemeralKey: Uint8Array 21 + uri: string; 22 + fingerprint: string; 23 + createdAt: string; 24 + ephemeralKey: Uint8Array; 25 25 } 26 26 27 27 interface PairResponseRecord { 28 - wrappedKey: WrappedKey 29 - ciphertext: AtBytes 30 - nonce: AtBytes 28 + wrappedKey: WrappedKey; 29 + ciphertext: AtBytes; 30 + nonce: AtBytes; 31 31 } 32 32 33 - const PAIR_REQUEST_COLLECTION = "app.opake.pairRequest" 34 - const PAIR_RESPONSE_COLLECTION = "app.opake.pairResponse" 35 - const SCHEMA_VERSION = 1 33 + const PAIR_REQUEST_COLLECTION = "app.opake.pairRequest"; 34 + const PAIR_RESPONSE_COLLECTION = "app.opake.pairResponse"; 35 + const SCHEMA_VERSION = 1; 36 36 37 37 // --------------------------------------------------------------------------- 38 38 // Create pair request (new device) ··· 45 45 ephemeralPubKey: Uint8Array, 46 46 session: Session, 47 47 ): Promise<string> { 48 - const rkey = generateTid() 48 + const rkey = generateTid(); 49 49 50 50 const record = { 51 51 $type: PAIR_REQUEST_COLLECTION, ··· 53 53 ephemeralKey: { $bytes: uint8ArrayToBase64(ephemeralPubKey) }, 54 54 algo: "x25519", 55 55 createdAt: new Date().toISOString(), 56 - } as const 56 + } as const; 57 57 58 58 const result = (await authenticatedXrpc( 59 59 { ··· 68 68 }, 69 69 }, 70 70 session, 71 - )) as { uri: string } 71 + )) as { uri: string }; 72 72 73 - return result.uri 73 + return result.uri; 74 74 } 75 75 76 76 // --------------------------------------------------------------------------- ··· 81 81 pdsUrl: string, 82 82 did: string, 83 83 session: Session, 84 + maxAge: number, 84 85 ): Promise<PendingPairRequest[]> { 85 86 const result = (await authenticatedXrpc( 86 87 { ··· 91 92 session, 92 93 )) as { 93 94 records: { 94 - uri: string 95 + uri: string; 95 96 value: { 96 - ephemeralKey: AtBytes 97 - createdAt: string 98 - } 99 - }[] 100 - } 97 + ephemeralKey: AtBytes; 98 + createdAt: string; 99 + }; 100 + }[]; 101 + }; 101 102 102 - return result.records.map((rec) => { 103 - const keyBytes = base64ToUint8Array(rec.value.ephemeralKey.$bytes) 104 - return { 105 - uri: rec.uri, 106 - fingerprint: formatFingerprint(keyBytes), 107 - createdAt: rec.value.createdAt, 108 - ephemeralKey: keyBytes, 109 - } 110 - }) 103 + return result.records 104 + .filter((rec) => { 105 + const now = Date.now(); 106 + const then = Date.parse(rec.value.createdAt); 107 + 108 + return now - then < maxAge; 109 + }) 110 + .map((rec) => { 111 + const keyBytes = base64ToUint8Array(rec.value.ephemeralKey.$bytes); 112 + return { 113 + uri: rec.uri, 114 + fingerprint: formatFingerprint(keyBytes), 115 + createdAt: rec.value.createdAt, 116 + ephemeralKey: keyBytes, 117 + }; 118 + }); 111 119 } 112 120 113 121 // --------------------------------------------------------------------------- ··· 129 137 session, 130 138 )) as { 131 139 records: { 132 - uri: string 140 + uri: string; 133 141 value: { 134 - request: string 135 - wrappedKey: WrappedKey 136 - ciphertext: AtBytes 137 - nonce: AtBytes 138 - } 139 - }[] 140 - } 142 + request: string; 143 + wrappedKey: WrappedKey; 144 + ciphertext: AtBytes; 145 + nonce: AtBytes; 146 + }; 147 + }[]; 148 + }; 141 149 142 150 // Find the response that references our request 143 - const requestUri = `at://${did}/${PAIR_REQUEST_COLLECTION}/${requestRkey}` 144 - const match = result.records.find((rec) => rec.value.request === requestUri) 145 - if (!match) return null 151 + const requestUri = `at://${did}/${PAIR_REQUEST_COLLECTION}/${requestRkey}`; 152 + const match = result.records.find((rec) => rec.value.request === requestUri); 153 + if (!match) return null; 146 154 147 155 return { 148 156 wrappedKey: match.value.wrappedKey, 149 157 ciphertext: match.value.ciphertext, 150 158 nonce: match.value.nonce, 151 - } 159 + }; 152 160 } 153 161 154 162 // --------------------------------------------------------------------------- ··· 161 169 worker: Remote<CryptoApi>, 162 170 ): Promise<Identity> { 163 171 // Unwrap the content key using the ephemeral private key 164 - const contentKey = await worker.unwrapKey(response.wrappedKey, ephemeralPrivKey) 172 + const contentKey = await worker.unwrapKey(response.wrappedKey, ephemeralPrivKey); 165 173 166 174 // Decrypt the identity JSON 167 - const ciphertext = base64ToUint8Array(response.ciphertext.$bytes) 168 - const nonce = base64ToUint8Array(response.nonce.$bytes) 169 - const plaintext = await worker.decryptBlob(contentKey, ciphertext, nonce) 175 + const ciphertext = base64ToUint8Array(response.ciphertext.$bytes); 176 + const nonce = base64ToUint8Array(response.nonce.$bytes); 177 + const plaintext = await worker.decryptBlob(contentKey, ciphertext, nonce); 170 178 171 179 // Parse the identity (mirrors Rust's serde_json::from_slice) 172 - const decoder = new TextDecoder() 173 - const identity = JSON.parse(decoder.decode(plaintext)) as Identity 180 + const decoder = new TextDecoder(); 181 + const identity = JSON.parse(decoder.decode(plaintext)) as Identity; 174 182 175 - return identity 183 + return identity; 176 184 } 177 185 178 186 // --------------------------------------------------------------------------- ··· 189 197 worker: Remote<CryptoApi>, 190 198 ): Promise<string> { 191 199 // Generate a content key for encrypting the identity 192 - const contentKey = await worker.generateContentKey() 200 + const contentKey = await worker.generateContentKey(); 193 201 194 202 // Serialize identity to JSON (mirrors Rust's serde_json::to_vec) 195 - const encoder = new TextEncoder() 196 - const plaintext = encoder.encode(JSON.stringify(identity)) 203 + const encoder = new TextEncoder(); 204 + const plaintext = encoder.encode(JSON.stringify(identity)); 197 205 198 206 // Encrypt identity with the content key 199 - const encrypted = await worker.encryptBlob(contentKey, plaintext) 207 + const encrypted = await worker.encryptBlob(contentKey, plaintext); 200 208 201 209 // Wrap the content key to the requester's ephemeral public key 202 - const wrappedKey = await worker.wrapKey(contentKey, ephemeralPubKey, did) 210 + const wrappedKey = await worker.wrapKey(contentKey, ephemeralPubKey, did); 203 211 204 - const rkey = generateTid() 212 + const rkey = generateTid(); 205 213 206 214 const record = { 207 215 $type: PAIR_RESPONSE_COLLECTION, ··· 212 220 nonce: { $bytes: uint8ArrayToBase64(encrypted.nonce) }, 213 221 algo: "aes-256-gcm", 214 222 createdAt: new Date().toISOString(), 215 - } as const 223 + } as const; 216 224 217 225 const result = (await authenticatedXrpc( 218 226 { ··· 227 235 }, 228 236 }, 229 237 session, 230 - )) as { uri: string } 238 + )) as { uri: string }; 231 239 232 - return result.uri 240 + return result.uri; 233 241 } 234 242 235 243 // --------------------------------------------------------------------------- ··· 244 252 session: Session, 245 253 ): Promise<void> { 246 254 const deleteRecord = async (collection: string, uri: string) => { 247 - const rkey = rkeyFromUri(uri) 255 + const rkey = rkeyFromUri(uri); 248 256 await authenticatedXrpc( 249 257 { 250 258 pdsUrl, ··· 253 261 body: { repo: did, collection, rkey }, 254 262 }, 255 263 session, 256 - ) 257 - } 264 + ); 265 + }; 258 266 259 - await deleteRecord(PAIR_REQUEST_COLLECTION, requestUri) 267 + await deleteRecord(PAIR_REQUEST_COLLECTION, requestUri); 260 268 if (responseUri) { 261 - await deleteRecord(PAIR_RESPONSE_COLLECTION, responseUri) 269 + await deleteRecord(PAIR_RESPONSE_COLLECTION, responseUri); 262 270 } 263 271 } 264 272 ··· 266 274 // TID generation (AT Protocol timestamp-based ID) 267 275 // --------------------------------------------------------------------------- 268 276 269 - const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz" 277 + const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 270 278 271 279 function generateTid(): string { 272 - const now = BigInt(Date.now()) * 1000n 280 + const now = BigInt(Date.now()) * 1000n; 273 281 // eslint-disable-next-line sonarjs/pseudo-random -- not security-sensitive; clock ID is only for TID collision avoidance 274 - const clockId = BigInt(Math.floor(Math.random() * 1024)) 275 - const tid = (now << 10n) | clockId 282 + const clockId = BigInt(Math.floor(Math.random() * 1024)); 283 + const tid = (now << 10n) | clockId; 276 284 277 285 // eslint-disable-next-line functional/no-let -- bit-manipulation algorithm for base32 encoding 278 - let result = "" 286 + let result = ""; 279 287 // eslint-disable-next-line functional/no-let 280 - let remaining = tid 288 + let remaining = tid; 281 289 // eslint-disable-next-line functional/no-loop-statements, functional/no-let 282 290 for (let i = 0; i < 13; i++) { 283 - result = TID_CHARS[Number(remaining & 31n)] + result 284 - remaining >>= 5n 291 + result = TID_CHARS[Number(remaining & 31n)] + result; 292 + remaining >>= 5n; 285 293 } 286 294 287 - return result 295 + return result; 288 296 }
+40 -17
web/src/main.tsx
··· 1 - import { enableMapSet, enableArrayMethods } from "immer" 2 - import { StrictMode } from "react" 3 - import { createRoot } from "react-dom/client" 4 - import { createRouter, RouterProvider } from "@tanstack/react-router" 5 - import { routeTree } from "./routeTree.gen" 6 - import "./index.css" 7 - import { getCryptoWorker } from "@/lib/worker" 1 + import { enableMapSet, enableArrayMethods } from "immer"; 2 + import { StrictMode, useEffect } from "react"; 3 + import { createRoot } from "react-dom/client"; 4 + import { createRouter, RouterProvider } from "@tanstack/react-router"; 5 + import { routeTree } from "./routeTree.gen"; 6 + import { useAuthStore } from "@/stores/auth"; 7 + import type { RouterContext } from "@/routes/__root"; 8 + import "./index.css"; 9 + import { getCryptoWorker } from "@/lib/worker"; 8 10 9 - enableMapSet() 10 - enableArrayMethods() 11 + enableMapSet(); 12 + enableArrayMethods(); 11 13 12 - console.debug("[opake] app starting") 13 - getCryptoWorker() // warm up WASM worker early 14 + console.debug("[opake] app starting"); 15 + getCryptoWorker(); // warm up WASM worker early 14 16 15 - const router = createRouter({ routeTree }) 17 + const router = createRouter({ 18 + routeTree, 19 + context: {} as RouterContext, 20 + }); 16 21 17 22 declare module "@tanstack/react-router" { 18 23 interface Register { 19 - router: typeof router 24 + router: typeof router; 20 25 } 21 26 } 22 27 23 - const rootElement = document.getElementById("root") 24 - if (!rootElement) throw new Error("Missing #root element") 28 + function App() { 29 + const session = useAuthStore((s) => s.session); 30 + const identity = useAuthStore((s) => s.identity); 31 + useEffect(() => { 32 + if (session.status === "initializing") { 33 + void useAuthStore.getState().boot(); 34 + } 35 + }, [session.status]); 36 + 37 + // Don't render the router until boot resolves — route guards would see 38 + // "initializing" as not-active and redirect to login before IndexedDB loads. 39 + if (session.status === "initializing") { 40 + return null; 41 + } 42 + 43 + return <RouterProvider router={router} context={{ auth: { session, identity } }} />; 44 + } 45 + 46 + const rootElement = document.getElementById("root"); 47 + if (!rootElement) throw new Error("Missing #root element"); 25 48 26 49 createRoot(rootElement).render( 27 50 <StrictMode> 28 - <RouterProvider router={router} /> 51 + <App /> 29 52 </StrictMode>, 30 - ) 53 + );
+147 -106
web/src/routeTree.gen.ts
··· 9 9 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 - import { Route as LoginRouteImport } from './routes/login' 13 12 import { Route as CabinetRouteImport } from './routes/cabinet' 13 + import { Route as DevicesRouteRouteImport } from './routes/devices/route' 14 14 import { Route as IndexRouteImport } from './routes/index' 15 - import { Route as OauthCliCallbackRouteImport } from './routes/oauth.cli-callback' 16 - import { Route as OauthCallbackRouteImport } from './routes/oauth.callback' 17 - import { Route as CabinetDevicesIndexRouteImport } from './routes/cabinet.devices.index' 18 - import { Route as CabinetDevicesPairRouteImport } from './routes/cabinet.devices.pair' 15 + import { Route as DevicesIndexRouteImport } from './routes/devices/index' 16 + import { Route as DevicesOauthCallbackRouteImport } from './routes/devices/oauth-callback' 17 + import { Route as DevicesLoginRouteImport } from './routes/devices/login' 18 + import { Route as DevicesCliCallbackRouteImport } from './routes/devices/cli-callback' 19 + import { Route as DevicesPairRequestRouteImport } from './routes/devices/pair.request' 20 + import { Route as DevicesPairAcceptRouteImport } from './routes/devices/pair.accept' 19 21 20 - const LoginRoute = LoginRouteImport.update({ 21 - id: '/login', 22 - path: '/login', 23 - getParentRoute: () => rootRouteImport, 24 - } as any) 25 22 const CabinetRoute = CabinetRouteImport.update({ 26 23 id: '/cabinet', 27 24 path: '/cabinet', 28 25 getParentRoute: () => rootRouteImport, 29 26 } as any) 27 + const DevicesRouteRoute = DevicesRouteRouteImport.update({ 28 + id: '/devices', 29 + path: '/devices', 30 + getParentRoute: () => rootRouteImport, 31 + } as any) 30 32 const IndexRoute = IndexRouteImport.update({ 31 33 id: '/', 32 34 path: '/', 33 35 getParentRoute: () => rootRouteImport, 34 36 } as any) 35 - const OauthCliCallbackRoute = OauthCliCallbackRouteImport.update({ 36 - id: '/oauth/cli-callback', 37 - path: '/oauth/cli-callback', 38 - getParentRoute: () => rootRouteImport, 37 + const DevicesIndexRoute = DevicesIndexRouteImport.update({ 38 + id: '/', 39 + path: '/', 40 + getParentRoute: () => DevicesRouteRoute, 39 41 } as any) 40 - const OauthCallbackRoute = OauthCallbackRouteImport.update({ 41 - id: '/oauth/callback', 42 - path: '/oauth/callback', 43 - getParentRoute: () => rootRouteImport, 42 + const DevicesOauthCallbackRoute = DevicesOauthCallbackRouteImport.update({ 43 + id: '/oauth-callback', 44 + path: '/oauth-callback', 45 + getParentRoute: () => DevicesRouteRoute, 44 46 } as any) 45 - const CabinetDevicesIndexRoute = CabinetDevicesIndexRouteImport.update({ 46 - id: '/devices/', 47 - path: '/devices/', 48 - getParentRoute: () => CabinetRoute, 47 + const DevicesLoginRoute = DevicesLoginRouteImport.update({ 48 + id: '/login', 49 + path: '/login', 50 + getParentRoute: () => DevicesRouteRoute, 49 51 } as any) 50 - const CabinetDevicesPairRoute = CabinetDevicesPairRouteImport.update({ 51 - id: '/devices/pair', 52 - path: '/devices/pair', 53 - getParentRoute: () => CabinetRoute, 52 + const DevicesCliCallbackRoute = DevicesCliCallbackRouteImport.update({ 53 + id: '/cli-callback', 54 + path: '/cli-callback', 55 + getParentRoute: () => DevicesRouteRoute, 56 + } as any) 57 + const DevicesPairRequestRoute = DevicesPairRequestRouteImport.update({ 58 + id: '/pair/request', 59 + path: '/pair/request', 60 + getParentRoute: () => DevicesRouteRoute, 61 + } as any) 62 + const DevicesPairAcceptRoute = DevicesPairAcceptRouteImport.update({ 63 + id: '/pair/accept', 64 + path: '/pair/accept', 65 + getParentRoute: () => DevicesRouteRoute, 54 66 } as any) 55 67 56 68 export interface FileRoutesByFullPath { 57 69 '/': typeof IndexRoute 58 - '/cabinet': typeof CabinetRouteWithChildren 59 - '/login': typeof LoginRoute 60 - '/oauth/callback': typeof OauthCallbackRoute 61 - '/oauth/cli-callback': typeof OauthCliCallbackRoute 62 - '/cabinet/devices/pair': typeof CabinetDevicesPairRoute 63 - '/cabinet/devices/': typeof CabinetDevicesIndexRoute 70 + '/devices': typeof DevicesRouteRouteWithChildren 71 + '/cabinet': typeof CabinetRoute 72 + '/devices/cli-callback': typeof DevicesCliCallbackRoute 73 + '/devices/login': typeof DevicesLoginRoute 74 + '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 75 + '/devices/': typeof DevicesIndexRoute 76 + '/devices/pair/accept': typeof DevicesPairAcceptRoute 77 + '/devices/pair/request': typeof DevicesPairRequestRoute 64 78 } 65 79 export interface FileRoutesByTo { 66 80 '/': typeof IndexRoute 67 - '/cabinet': typeof CabinetRouteWithChildren 68 - '/login': typeof LoginRoute 69 - '/oauth/callback': typeof OauthCallbackRoute 70 - '/oauth/cli-callback': typeof OauthCliCallbackRoute 71 - '/cabinet/devices/pair': typeof CabinetDevicesPairRoute 72 - '/cabinet/devices': typeof CabinetDevicesIndexRoute 81 + '/cabinet': typeof CabinetRoute 82 + '/devices/cli-callback': typeof DevicesCliCallbackRoute 83 + '/devices/login': typeof DevicesLoginRoute 84 + '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 85 + '/devices': typeof DevicesIndexRoute 86 + '/devices/pair/accept': typeof DevicesPairAcceptRoute 87 + '/devices/pair/request': typeof DevicesPairRequestRoute 73 88 } 74 89 export interface FileRoutesById { 75 90 __root__: typeof rootRouteImport 76 91 '/': typeof IndexRoute 77 - '/cabinet': typeof CabinetRouteWithChildren 78 - '/login': typeof LoginRoute 79 - '/oauth/callback': typeof OauthCallbackRoute 80 - '/oauth/cli-callback': typeof OauthCliCallbackRoute 81 - '/cabinet/devices/pair': typeof CabinetDevicesPairRoute 82 - '/cabinet/devices/': typeof CabinetDevicesIndexRoute 92 + '/devices': typeof DevicesRouteRouteWithChildren 93 + '/cabinet': typeof CabinetRoute 94 + '/devices/cli-callback': typeof DevicesCliCallbackRoute 95 + '/devices/login': typeof DevicesLoginRoute 96 + '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 97 + '/devices/': typeof DevicesIndexRoute 98 + '/devices/pair/accept': typeof DevicesPairAcceptRoute 99 + '/devices/pair/request': typeof DevicesPairRequestRoute 83 100 } 84 101 export interface FileRouteTypes { 85 102 fileRoutesByFullPath: FileRoutesByFullPath 86 103 fullPaths: 87 104 | '/' 105 + | '/devices' 88 106 | '/cabinet' 89 - | '/login' 90 - | '/oauth/callback' 91 - | '/oauth/cli-callback' 92 - | '/cabinet/devices/pair' 93 - | '/cabinet/devices/' 107 + | '/devices/cli-callback' 108 + | '/devices/login' 109 + | '/devices/oauth-callback' 110 + | '/devices/' 111 + | '/devices/pair/accept' 112 + | '/devices/pair/request' 94 113 fileRoutesByTo: FileRoutesByTo 95 114 to: 96 115 | '/' 97 116 | '/cabinet' 98 - | '/login' 99 - | '/oauth/callback' 100 - | '/oauth/cli-callback' 101 - | '/cabinet/devices/pair' 102 - | '/cabinet/devices' 117 + | '/devices/cli-callback' 118 + | '/devices/login' 119 + | '/devices/oauth-callback' 120 + | '/devices' 121 + | '/devices/pair/accept' 122 + | '/devices/pair/request' 103 123 id: 104 124 | '__root__' 105 125 | '/' 126 + | '/devices' 106 127 | '/cabinet' 107 - | '/login' 108 - | '/oauth/callback' 109 - | '/oauth/cli-callback' 110 - | '/cabinet/devices/pair' 111 - | '/cabinet/devices/' 128 + | '/devices/cli-callback' 129 + | '/devices/login' 130 + | '/devices/oauth-callback' 131 + | '/devices/' 132 + | '/devices/pair/accept' 133 + | '/devices/pair/request' 112 134 fileRoutesById: FileRoutesById 113 135 } 114 136 export interface RootRouteChildren { 115 137 IndexRoute: typeof IndexRoute 116 - CabinetRoute: typeof CabinetRouteWithChildren 117 - LoginRoute: typeof LoginRoute 118 - OauthCallbackRoute: typeof OauthCallbackRoute 119 - OauthCliCallbackRoute: typeof OauthCliCallbackRoute 138 + DevicesRouteRoute: typeof DevicesRouteRouteWithChildren 139 + CabinetRoute: typeof CabinetRoute 120 140 } 121 141 122 142 declare module '@tanstack/react-router' { 123 143 interface FileRoutesByPath { 124 - '/login': { 125 - id: '/login' 126 - path: '/login' 127 - fullPath: '/login' 128 - preLoaderRoute: typeof LoginRouteImport 129 - parentRoute: typeof rootRouteImport 130 - } 131 144 '/cabinet': { 132 145 id: '/cabinet' 133 146 path: '/cabinet' ··· 135 148 preLoaderRoute: typeof CabinetRouteImport 136 149 parentRoute: typeof rootRouteImport 137 150 } 151 + '/devices': { 152 + id: '/devices' 153 + path: '/devices' 154 + fullPath: '/devices' 155 + preLoaderRoute: typeof DevicesRouteRouteImport 156 + parentRoute: typeof rootRouteImport 157 + } 138 158 '/': { 139 159 id: '/' 140 160 path: '/' ··· 142 162 preLoaderRoute: typeof IndexRouteImport 143 163 parentRoute: typeof rootRouteImport 144 164 } 145 - '/oauth/cli-callback': { 146 - id: '/oauth/cli-callback' 147 - path: '/oauth/cli-callback' 148 - fullPath: '/oauth/cli-callback' 149 - preLoaderRoute: typeof OauthCliCallbackRouteImport 150 - parentRoute: typeof rootRouteImport 165 + '/devices/': { 166 + id: '/devices/' 167 + path: '/' 168 + fullPath: '/devices/' 169 + preLoaderRoute: typeof DevicesIndexRouteImport 170 + parentRoute: typeof DevicesRouteRoute 151 171 } 152 - '/oauth/callback': { 153 - id: '/oauth/callback' 154 - path: '/oauth/callback' 155 - fullPath: '/oauth/callback' 156 - preLoaderRoute: typeof OauthCallbackRouteImport 157 - parentRoute: typeof rootRouteImport 172 + '/devices/oauth-callback': { 173 + id: '/devices/oauth-callback' 174 + path: '/oauth-callback' 175 + fullPath: '/devices/oauth-callback' 176 + preLoaderRoute: typeof DevicesOauthCallbackRouteImport 177 + parentRoute: typeof DevicesRouteRoute 158 178 } 159 - '/cabinet/devices/': { 160 - id: '/cabinet/devices/' 161 - path: '/devices' 162 - fullPath: '/cabinet/devices/' 163 - preLoaderRoute: typeof CabinetDevicesIndexRouteImport 164 - parentRoute: typeof CabinetRoute 179 + '/devices/login': { 180 + id: '/devices/login' 181 + path: '/login' 182 + fullPath: '/devices/login' 183 + preLoaderRoute: typeof DevicesLoginRouteImport 184 + parentRoute: typeof DevicesRouteRoute 165 185 } 166 - '/cabinet/devices/pair': { 167 - id: '/cabinet/devices/pair' 168 - path: '/devices/pair' 169 - fullPath: '/cabinet/devices/pair' 170 - preLoaderRoute: typeof CabinetDevicesPairRouteImport 171 - parentRoute: typeof CabinetRoute 186 + '/devices/cli-callback': { 187 + id: '/devices/cli-callback' 188 + path: '/cli-callback' 189 + fullPath: '/devices/cli-callback' 190 + preLoaderRoute: typeof DevicesCliCallbackRouteImport 191 + parentRoute: typeof DevicesRouteRoute 192 + } 193 + '/devices/pair/request': { 194 + id: '/devices/pair/request' 195 + path: '/pair/request' 196 + fullPath: '/devices/pair/request' 197 + preLoaderRoute: typeof DevicesPairRequestRouteImport 198 + parentRoute: typeof DevicesRouteRoute 199 + } 200 + '/devices/pair/accept': { 201 + id: '/devices/pair/accept' 202 + path: '/pair/accept' 203 + fullPath: '/devices/pair/accept' 204 + preLoaderRoute: typeof DevicesPairAcceptRouteImport 205 + parentRoute: typeof DevicesRouteRoute 172 206 } 173 207 } 174 208 } 175 209 176 - interface CabinetRouteChildren { 177 - CabinetDevicesPairRoute: typeof CabinetDevicesPairRoute 178 - CabinetDevicesIndexRoute: typeof CabinetDevicesIndexRoute 210 + interface DevicesRouteRouteChildren { 211 + DevicesCliCallbackRoute: typeof DevicesCliCallbackRoute 212 + DevicesLoginRoute: typeof DevicesLoginRoute 213 + DevicesOauthCallbackRoute: typeof DevicesOauthCallbackRoute 214 + DevicesIndexRoute: typeof DevicesIndexRoute 215 + DevicesPairAcceptRoute: typeof DevicesPairAcceptRoute 216 + DevicesPairRequestRoute: typeof DevicesPairRequestRoute 179 217 } 180 218 181 - const CabinetRouteChildren: CabinetRouteChildren = { 182 - CabinetDevicesPairRoute: CabinetDevicesPairRoute, 183 - CabinetDevicesIndexRoute: CabinetDevicesIndexRoute, 219 + const DevicesRouteRouteChildren: DevicesRouteRouteChildren = { 220 + DevicesCliCallbackRoute: DevicesCliCallbackRoute, 221 + DevicesLoginRoute: DevicesLoginRoute, 222 + DevicesOauthCallbackRoute: DevicesOauthCallbackRoute, 223 + DevicesIndexRoute: DevicesIndexRoute, 224 + DevicesPairAcceptRoute: DevicesPairAcceptRoute, 225 + DevicesPairRequestRoute: DevicesPairRequestRoute, 184 226 } 185 227 186 - const CabinetRouteWithChildren = 187 - CabinetRoute._addFileChildren(CabinetRouteChildren) 228 + const DevicesRouteRouteWithChildren = DevicesRouteRoute._addFileChildren( 229 + DevicesRouteRouteChildren, 230 + ) 188 231 189 232 const rootRouteChildren: RootRouteChildren = { 190 233 IndexRoute: IndexRoute, 191 - CabinetRoute: CabinetRouteWithChildren, 192 - LoginRoute: LoginRoute, 193 - OauthCallbackRoute: OauthCallbackRoute, 194 - OauthCliCallbackRoute: OauthCliCallbackRoute, 234 + DevicesRouteRoute: DevicesRouteRouteWithChildren, 235 + CabinetRoute: CabinetRoute, 195 236 } 196 237 export const routeTree = rootRouteImport 197 238 ._addFileChildren(rootRouteChildren)
+7 -9
web/src/routes/__root.tsx
··· 1 - import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router" 2 - import { useAuthStore } from "@/stores/auth" 1 + import { createRootRouteWithContext, Outlet, useRouter } from "@tanstack/react-router" 2 + import type { AuthSnapshot } from "@/stores/auth" 3 + 4 + export interface RouterContext { 5 + auth: AuthSnapshot 6 + } 3 7 4 8 function RootLayout() { 5 9 return <Outlet /> ··· 26 30 ) 27 31 } 28 32 29 - export const Route = createRootRoute({ 30 - beforeLoad: async () => { 31 - const state = useAuthStore.getState() 32 - if (state.phase === "initializing") { 33 - await state.boot() 34 - } 35 - }, 33 + export const Route = createRootRouteWithContext<RouterContext>()({ 36 34 component: RootLayout, 37 35 errorComponent: RootError, 38 36 })
-93
web/src/routes/cabinet.devices.index.tsx
··· 1 - import { createFileRoute, redirect, Link } from "@tanstack/react-router" 2 - import { OpakeLogo } from "@/components/OpakeLogo" 3 - import { useAuthStore } from "@/stores/auth" 4 - import { ArrowsLeftRightIcon, KeyIcon } from "@phosphor-icons/react" 5 - 6 - function DevicesPage() { 7 - const phase = useAuthStore((s) => s.phase) 8 - 9 - if (phase === "ready") { 10 - return <ActiveIdentityView /> 11 - } 12 - 13 - return <IdentityRequiredView /> 14 - } 15 - 16 - /** UserIcon already has identity on this device — show device management. */ 17 - function ActiveIdentityView() { 18 - return ( 19 - <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 20 - <div className="flex w-full max-w-lg flex-col items-center gap-8 px-6 py-12"> 21 - <OpakeLogo size="lg" /> 22 - 23 - <div className="text-center"> 24 - <h1 className="text-base-content text-2xl font-semibold">Device identity active</h1> 25 - <p className="text-base-content/60 mt-2 text-sm"> 26 - This device has an encryption identity. You can approve pairing requests from other 27 - devices. 28 - </p> 29 - </div> 30 - 31 - <Link to="/cabinet/devices/pair" className="btn btn-neutral w-full max-w-xs"> 32 - <ArrowsLeftRightIcon size={20} aria-hidden="true" /> 33 - Manage pairing 34 - </Link> 35 - 36 - <Link to="/cabinet" className="text-base-content/50 hover:text-base-content/70 text-sm"> 37 - Back to cabinet 38 - </Link> 39 - </div> 40 - </div> 41 - ) 42 - } 43 - 44 - /** UserIcon needs to get their identity onto this device. */ 45 - function IdentityRequiredView() { 46 - return ( 47 - <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 48 - <div className="flex w-full max-w-lg flex-col items-center gap-8 px-6 py-12"> 49 - <OpakeLogo size="lg" /> 50 - 51 - <div className="text-center"> 52 - <h1 className="text-base-content text-2xl font-semibold">Set up encryption</h1> 53 - <p className="text-base-content/60 mt-2 text-sm"> 54 - Your account has an encryption identity on another device. Transfer it to use Opake 55 - here. 56 - </p> 57 - </div> 58 - 59 - <div className="grid w-full max-w-md gap-4 sm:grid-cols-2"> 60 - <Link 61 - to="/cabinet/devices/pair" 62 - className="card card-bordered bg-base-100 hover:border-primary/40 p-5 transition-colors" 63 - > 64 - <ArrowsLeftRightIcon size={24} className="text-primary mb-3" aria-hidden="true" /> 65 - <h2 className="text-base-content font-medium">Pair with existing device</h2> 66 - <p className="text-caption text-base-content/60 mt-1"> 67 - Transfer your encryption identity from another device. 68 - </p> 69 - </Link> 70 - 71 - <div className="card card-bordered bg-base-100 p-5 opacity-50" aria-disabled="true"> 72 - <KeyIcon size={24} className="text-base-content/40 mb-3" aria-hidden="true" /> 73 - <h2 className="text-base-content font-medium">Recover from seed phrase</h2> 74 - <p className="text-caption text-base-content/60 mt-1"> 75 - Restore your identity from your backup phrase. 76 - </p> 77 - <span className="text-base-content/40 mt-2 inline-block text-xs">Coming soon</span> 78 - </div> 79 - </div> 80 - </div> 81 - </div> 82 - ) 83 - } 84 - 85 - export const Route = createFileRoute("/cabinet/devices/")({ 86 - beforeLoad: () => { 87 - const state = useAuthStore.getState() 88 - if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 89 - throw redirect({ to: "/login" }) 90 - } 91 - }, 92 - component: DevicesPage, 93 - })
-414
web/src/routes/cabinet.devices.pair.tsx
··· 1 - import { useCallback, useEffect, useMemo, useRef, useState } from "react" 2 - import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" 3 - import { OpakeLogo } from "@/components/OpakeLogo" 4 - import { useAuthStore } from "@/stores/auth" 5 - import { getCryptoWorker } from "@/lib/worker" 6 - import { IndexedDbStorage } from "@/lib/indexeddb-storage" 7 - import { formatFingerprint } from "@/lib/encoding" 8 - import { rkeyFromUri } from "@/lib/encoding" 9 - import { 10 - createPairRequest, 11 - listPairRequests, 12 - pollForPairResponse, 13 - receivePairResponse, 14 - approvePairRequest, 15 - cleanupPairRecords, 16 - type PendingPairRequest, 17 - } from "@/lib/pairing" 18 - import { CheckCircleIcon, WarningIcon } from "@phosphor-icons/react" 19 - 20 - const POLL_INTERVAL_MS = 3000 21 - 22 - const storage = new IndexedDbStorage() 23 - 24 - // --------------------------------------------------------------------------- 25 - // Route 26 - // --------------------------------------------------------------------------- 27 - 28 - export const Route = createFileRoute("/cabinet/devices/pair")({ 29 - beforeLoad: () => { 30 - const state = useAuthStore.getState() 31 - if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 32 - throw redirect({ to: "/login" }) 33 - } 34 - }, 35 - component: PairPage, 36 - }) 37 - 38 - // --------------------------------------------------------------------------- 39 - // Page component — dispatches to request or approve mode 40 - // --------------------------------------------------------------------------- 41 - 42 - function PairPage() { 43 - const phase = useAuthStore((s) => s.phase) 44 - 45 - // Synchronously derive initial mode: "awaiting_identity" always means request, 46 - // "ready" needs an async identity check so starts as "loading". 47 - const initialMode = useMemo<"loading" | "request" | "approve">( 48 - () => (phase === "awaiting_identity" ? "request" : "loading"), 49 - [phase], 50 - ) 51 - const [mode, setMode] = useState(initialMode) 52 - 53 - useEffect(() => { 54 - // Only need the async probe when phase is "ready" 55 - if (phase !== "ready") return 56 - 57 - const state = useAuthStore.getState() 58 - if (state.phase !== "ready") return 59 - 60 - storage 61 - .loadIdentity(state.did) 62 - .then(() => setMode("approve")) 63 - .catch(() => setMode("request")) 64 - }, [phase]) 65 - 66 - if (mode === "loading") { 67 - return ( 68 - <PageShell> 69 - <span className="loading loading-spinner loading-lg text-primary" /> 70 - </PageShell> 71 - ) 72 - } 73 - 74 - if (mode === "request") { 75 - return <RequestMode /> 76 - } 77 - 78 - return <ApproveMode /> 79 - } 80 - 81 - // --------------------------------------------------------------------------- 82 - // Request mode — new device requesting identity 83 - // --------------------------------------------------------------------------- 84 - 85 - type RequestState = 86 - | { step: "generating" } 87 - | { step: "waiting"; fingerprint: string; requestUri: string } 88 - | { step: "receiving" } 89 - | { step: "success" } 90 - | { step: "error"; message: string } 91 - 92 - function RequestMode() { 93 - const navigate = useNavigate() 94 - const [state, setState] = useState<RequestState>({ step: "generating" }) 95 - const ephemeralPrivKeyRef = useRef<Uint8Array | null>(null) 96 - const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) 97 - 98 - const cleanup = useCallback(() => { 99 - if (pollRef.current) { 100 - clearInterval(pollRef.current) 101 - pollRef.current = null 102 - } 103 - }, []) 104 - 105 - useEffect(() => { 106 - const cancelledRef = { current: false } 107 - 108 - async function init() { 109 - const authState = useAuthStore.getState() 110 - if (authState.phase !== "awaiting_identity" && authState.phase !== "ready") return 111 - 112 - const { did, pdsUrl } = authState 113 - const worker = getCryptoWorker() 114 - const session = await storage.loadSession(did) 115 - 116 - try { 117 - // Generate ephemeral keypair 118 - const ephemeral = await worker.generateEphemeralKeypair() 119 - ephemeralPrivKeyRef.current = ephemeral.privateKey 120 - 121 - // Create pair request on PDS 122 - const requestUri = await createPairRequest(pdsUrl, did, ephemeral.publicKey, session) 123 - 124 - if (cancelledRef.current) return 125 - 126 - const fingerprint = formatFingerprint(ephemeral.publicKey) 127 - const requestRkey = rkeyFromUri(requestUri) 128 - setState({ step: "waiting", fingerprint, requestUri }) 129 - 130 - // Poll for response 131 - pollRef.current = setInterval(async () => { 132 - try { 133 - const response = await pollForPairResponse(pdsUrl, did, requestRkey, session) 134 - if (!response || cancelledRef.current) return 135 - 136 - cleanup() 137 - setState({ step: "receiving" }) 138 - 139 - const privKey = ephemeralPrivKeyRef.current 140 - if (!privKey) { 141 - setState({ step: "error", message: "Ephemeral private key unavailable" }) 142 - return 143 - } 144 - 145 - const identity = await receivePairResponse(response, privKey, worker) 146 - 147 - await storage.saveIdentity(did, identity) 148 - 149 - // Clean up PDS records (best-effort) 150 - await cleanupPairRecords(pdsUrl, did, requestUri, null, session).catch( 151 - Function.prototype as () => void, 152 - ) 153 - 154 - // Transition auth store to ready 155 - useAuthStore.setState({ 156 - phase: "ready", 157 - did, 158 - handle: authState.handle, 159 - pdsUrl, 160 - }) 161 - 162 - setState({ step: "success" }) 163 - setTimeout(() => navigate({ to: "/cabinet" }), 1500) 164 - } catch (err) { 165 - cleanup() 166 - setState({ 167 - step: "error", 168 - message: err instanceof Error ? err.message : String(err), 169 - }) 170 - } 171 - }, POLL_INTERVAL_MS) 172 - } catch (err) { 173 - if (cancelledRef.current) return 174 - setState({ 175 - step: "error", 176 - message: err instanceof Error ? err.message : String(err), 177 - }) 178 - } 179 - } 180 - 181 - void init() 182 - return () => { 183 - cancelledRef.current = true 184 - cleanup() 185 - } 186 - }, [cleanup, navigate]) 187 - 188 - return ( 189 - <PageShell> 190 - {state.step === "generating" && ( 191 - <div className="flex flex-col items-center gap-4"> 192 - <span className="loading loading-spinner loading-lg text-primary" /> 193 - <p className="text-base-content/60 text-sm">Generating keypair…</p> 194 - </div> 195 - )} 196 - 197 - {state.step === "waiting" && ( 198 - <div className="flex flex-col items-center gap-6 text-center"> 199 - <h1 className="text-base-content text-2xl font-semibold">Pair this device</h1> 200 - <p className="text-base-content/60 text-sm"> 201 - Approve this request from your existing device. Verify the fingerprint matches. 202 - </p> 203 - 204 - <div className="bg-base-100 text-primary rounded-lg px-6 py-4 font-mono text-lg tracking-wider"> 205 - {state.fingerprint} 206 - </div> 207 - 208 - <div className="text-base-content/50 flex items-center gap-2 text-sm"> 209 - <span className="loading loading-spinner loading-xs" /> 210 - Waiting for approval… 211 - </div> 212 - </div> 213 - )} 214 - 215 - {state.step === "receiving" && ( 216 - <div className="flex flex-col items-center gap-4"> 217 - <span className="loading loading-spinner loading-lg text-primary" /> 218 - <p className="text-base-content/60 text-sm">Receiving identity…</p> 219 - </div> 220 - )} 221 - 222 - {state.step === "success" && ( 223 - <div className="flex flex-col items-center gap-4"> 224 - <CheckCircleIcon size={48} className="text-success" weight="fill" /> 225 - <p className="text-base-content text-lg font-medium">Device paired</p> 226 - <p className="text-base-content/60 text-sm">Redirecting to cabinet…</p> 227 - </div> 228 - )} 229 - 230 - {state.step === "error" && <ErrorView message={state.message} />} 231 - </PageShell> 232 - ) 233 - } 234 - 235 - // --------------------------------------------------------------------------- 236 - // Approve mode — existing device approving a request 237 - // --------------------------------------------------------------------------- 238 - 239 - type ApproveState = 240 - | { step: "loading" } 241 - | { step: "empty" } 242 - | { step: "selecting"; requests: PendingPairRequest[] } 243 - | { step: "approving" } 244 - | { step: "success" } 245 - | { step: "error"; message: string } 246 - 247 - async function fetchPairRequests(): Promise<ApproveState> { 248 - const authState = useAuthStore.getState() 249 - if (authState.phase !== "ready") return { step: "loading" } 250 - 251 - const { did, pdsUrl } = authState 252 - const session = await storage.loadSession(did) 253 - 254 - try { 255 - const requests = await listPairRequests(pdsUrl, did, session) 256 - return requests.length === 0 ? { step: "empty" } : { step: "selecting", requests } 257 - } catch (err) { 258 - return { 259 - step: "error", 260 - message: err instanceof Error ? err.message : String(err), 261 - } 262 - } 263 - } 264 - 265 - function ApproveMode() { 266 - const [state, setState] = useState<ApproveState>({ step: "loading" }) 267 - const initialLoadDone = useRef(false) 268 - 269 - useEffect(() => { 270 - if (initialLoadDone.current) return 271 - initialLoadDone.current = true 272 - 273 - void fetchPairRequests().then(setState) 274 - }, []) 275 - 276 - const handleRefresh = useCallback(() => { 277 - setState({ step: "loading" }) 278 - void fetchPairRequests().then(setState) 279 - }, []) 280 - 281 - const handleApprove = useCallback(async (request: PendingPairRequest) => { 282 - setState({ step: "approving" }) 283 - 284 - const authState = useAuthStore.getState() 285 - if (authState.phase !== "ready") return 286 - 287 - const { did, pdsUrl } = authState 288 - const worker = getCryptoWorker() 289 - 290 - try { 291 - const session = await storage.loadSession(did) 292 - const identity = await storage.loadIdentity(did) 293 - 294 - await approvePairRequest( 295 - pdsUrl, 296 - did, 297 - request.uri, 298 - request.ephemeralKey, 299 - identity, 300 - session, 301 - worker, 302 - ) 303 - 304 - setState({ step: "success" }) 305 - } catch (err) { 306 - setState({ 307 - step: "error", 308 - message: err instanceof Error ? err.message : String(err), 309 - }) 310 - } 311 - }, []) 312 - 313 - return ( 314 - <PageShell> 315 - {state.step === "loading" && ( 316 - <div className="flex flex-col items-center gap-4"> 317 - <span className="loading loading-spinner loading-lg text-primary" /> 318 - <p className="text-base-content/60 text-sm">Loading pair requests…</p> 319 - </div> 320 - )} 321 - 322 - {state.step === "empty" && ( 323 - <div className="flex flex-col items-center gap-6 text-center"> 324 - <h1 className="text-base-content text-2xl font-semibold">No pending requests</h1> 325 - <p className="text-base-content/60 text-sm"> 326 - Start a pairing request from your new device first, then come back here to approve it. 327 - </p> 328 - <button onClick={handleRefresh} className="btn btn-neutral btn-sm"> 329 - Refresh 330 - </button> 331 - </div> 332 - )} 333 - 334 - {state.step === "selecting" && ( 335 - <div className="flex flex-col items-center gap-6"> 336 - <h1 className="text-base-content text-2xl font-semibold">Approve a device</h1> 337 - <p className="text-base-content/60 text-sm"> 338 - Verify the fingerprint matches what your new device shows. 339 - </p> 340 - 341 - <div className="flex w-full max-w-sm flex-col gap-3"> 342 - {state.requests.map((req) => ( 343 - <div key={req.uri} className="card card-bordered bg-base-100 p-4"> 344 - <div className="text-primary mb-2 font-mono text-sm tracking-wider"> 345 - {req.fingerprint} 346 - </div> 347 - <div className="text-base-content/50 mb-3 text-xs"> 348 - {new Date(req.createdAt).toLocaleString()} 349 - </div> 350 - <button 351 - onClick={() => { 352 - void handleApprove(req) 353 - }} 354 - className="btn btn-neutral btn-sm w-full" 355 - > 356 - Approve 357 - </button> 358 - </div> 359 - ))} 360 - </div> 361 - </div> 362 - )} 363 - 364 - {state.step === "approving" && ( 365 - <div className="flex flex-col items-center gap-4"> 366 - <span className="loading loading-spinner loading-lg text-primary" /> 367 - <p className="text-base-content/60 text-sm">Encrypting and sending identity…</p> 368 - </div> 369 - )} 370 - 371 - {state.step === "success" && ( 372 - <div className="flex flex-col items-center gap-4"> 373 - <CheckCircleIcon size={48} className="text-success" weight="fill" /> 374 - <p className="text-base-content text-lg font-medium">Approved</p> 375 - <p className="text-base-content/60 text-sm"> 376 - The other device should receive your identity shortly. 377 - </p> 378 - </div> 379 - )} 380 - 381 - {state.step === "error" && <ErrorView message={state.message} />} 382 - </PageShell> 383 - ) 384 - } 385 - 386 - // --------------------------------------------------------------------------- 387 - // Shared components 388 - // --------------------------------------------------------------------------- 389 - 390 - function PageShell({ children }: Readonly<{ children: React.ReactNode }>) { 391 - return ( 392 - <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 393 - <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 394 - <OpakeLogo size="lg" /> 395 - {children} 396 - </div> 397 - </div> 398 - ) 399 - } 400 - 401 - function ErrorView({ message }: Readonly<{ message: string }>) { 402 - return ( 403 - <div className="flex flex-col items-center gap-6 text-center"> 404 - <WarningIcon size={48} className="text-error" weight="fill" /> 405 - <div className="flex flex-col gap-2"> 406 - <h1 className="text-base-content text-2xl font-semibold">Pairing failed</h1> 407 - <p className="text-base-content/60">{message}</p> 408 - </div> 409 - <a href="/cabinet/devices" className="btn btn-neutral btn-sm"> 410 - Try again 411 - </a> 412 - </div> 413 - ) 414 - }
+5 -19
web/src/routes/cabinet.tsx
··· 1 1 import { useState } from "react" 2 - import { createFileRoute, redirect, Outlet, useMatch } from "@tanstack/react-router" 2 + import { createFileRoute, redirect } from "@tanstack/react-router" 3 3 import { Sidebar } from "@/components/cabinet/Sidebar" 4 4 import { TopBar } from "@/components/cabinet/TopBar" 5 5 import { PanelStack } from "@/components/cabinet/PanelStack" 6 - import { useAuthStore } from "@/stores/auth" 7 6 import type { FileItem, Panel, SectionType } from "@/components/cabinet/types" 8 - 9 - function CabinetLayout() { 10 - // If a child route matched (e.g. /cabinet/devices), render it instead of the cabinet UI 11 - const devicesMatch = useMatch({ from: "/cabinet/devices/", shouldThrow: false }) 12 - const pairMatch = useMatch({ from: "/cabinet/devices/pair", shouldThrow: false }) 13 - 14 - if (devicesMatch || pairMatch) { 15 - return <Outlet /> 16 - } 17 - 18 - return <CabinetPage /> 19 - } 20 7 21 8 function CabinetPage() { 22 9 const [panels, setPanels] = useState<Panel[]>([{ type: "root", title: "The Cabinet" }]) ··· 98 85 } 99 86 100 87 export const Route = createFileRoute("/cabinet")({ 101 - beforeLoad: () => { 102 - const state = useAuthStore.getState() 103 - if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 104 - throw redirect({ to: "/login" }) 88 + beforeLoad: ({ context }) => { 89 + if (context.auth.session.status !== "active") { 90 + throw redirect({ to: "/devices/login" }) 105 91 } 106 92 }, 107 - component: CabinetLayout, 93 + component: CabinetPage, 108 94 })
+76
web/src/routes/devices/cli-callback.tsx
··· 1 + import { CheckIcon } from "@phosphor-icons/react" 2 + import { useMemo, useEffect } from "react" 3 + import { createFileRoute } from "@tanstack/react-router" 4 + 5 + type CallbackResult = 6 + | { state: "success"; errorMessage: "" } 7 + | { state: "error"; errorMessage: string } 8 + 9 + function CliCallbackPage() { 10 + const { state, errorMessage } = useMemo<CallbackResult>(() => { 11 + const params = new URLSearchParams(window.location.search) 12 + const error = params.get("error") 13 + if (error) { 14 + return { state: "error", errorMessage: error } 15 + } 16 + return { state: "success", errorMessage: "" } 17 + }, []) 18 + 19 + useEffect(() => { 20 + window.history.replaceState({}, "", window.location.pathname) 21 + }, []) 22 + 23 + return ( 24 + <> 25 + {state === "success" && ( 26 + <div className="flex flex-col items-center gap-6 text-center"> 27 + <div className="bg-success/20 flex h-16 w-16 items-center justify-center rounded-full"> 28 + <CheckIcon size={32} /> 29 + </div> 30 + 31 + <div className="flex flex-col gap-2"> 32 + <h1 className="text-base-content text-2xl font-semibold">CLI login successful</h1> 33 + </div> 34 + 35 + <div className="text-base-content/50 mt-2 flex flex-col gap-3 text-sm"> 36 + <p>You can close this tab and return to your terminal.</p> 37 + <p> 38 + <span className="text-base-content/70 font-medium">Note:</span> This logs you into 39 + the CLI only. The web app requires a separate login. 40 + </p> 41 + </div> 42 + </div> 43 + )} 44 + 45 + {state === "error" && ( 46 + <div className="flex flex-col items-center gap-6 text-center"> 47 + <div className="bg-error/20 flex h-16 w-16 items-center justify-center rounded-full"> 48 + <svg 49 + className="text-error h-8 w-8" 50 + fill="none" 51 + viewBox="0 0 24 24" 52 + stroke="currentColor" 53 + strokeWidth={2} 54 + aria-hidden="true" 55 + > 56 + <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> 57 + </svg> 58 + </div> 59 + 60 + <div className="flex flex-col gap-2"> 61 + <h1 className="text-base-content text-2xl font-semibold">CLI login failed</h1> 62 + <p className="text-base-content/60">{errorMessage}</p> 63 + </div> 64 + 65 + <div className="text-base-content/50 mt-2 flex flex-col gap-3 text-sm"> 66 + <p>Please try again from your terminal.</p> 67 + </div> 68 + </div> 69 + )} 70 + </> 71 + ) 72 + } 73 + 74 + export const Route = createFileRoute("/devices/cli-callback")({ 75 + component: CliCallbackPage, 76 + })
+69
web/src/routes/devices/index.tsx
··· 1 + import { useEffect } from "react"; 2 + import { createFileRoute, redirect, useMatchRoute } from "@tanstack/react-router"; 3 + import { useAuthStore } from "@/stores/auth"; 4 + import { WarningIcon } from "@phosphor-icons/react"; 5 + import { PageHeader } from "@/components/devices/PageHeader"; 6 + import { CheckingView } from "@/components/devices/CheckingView"; 7 + import { FreshAccountView } from "@/components/devices/FreshAccountView"; 8 + import { RecoverIdentityView } from "@/components/devices/RecoverIdentityView"; 9 + import { ConflictView } from "@/components/devices/ConflictView"; 10 + import { ReadyView } from "@/components/devices/ReadyView"; 11 + import { useMinimumDuration } from "@/utils"; 12 + 13 + const MIN_CHECK_DISPLAY_MS = 2000; 14 + 15 + export const Route = createFileRoute("/devices/")({ 16 + beforeLoad: ({ context }) => { 17 + if (context.auth.session.status !== "active") { 18 + throw redirect({ to: "/devices/login" }); 19 + } 20 + }, 21 + component: DevicesPage, 22 + errorComponent: ErrorView, 23 + }); 24 + 25 + function DevicesPage() { 26 + const identity = useAuthStore((s) => s.identity); 27 + const minTimeElapsed = useMinimumDuration(MIN_CHECK_DISPLAY_MS); 28 + const matchRoute = useMatchRoute(); 29 + 30 + const childRouteActive = 31 + !!matchRoute({ to: "/devices/pair/request", fuzzy: true }) || 32 + !!matchRoute({ to: "/devices/pair/accept", fuzzy: true }); 33 + 34 + useEffect(() => { 35 + if (childRouteActive) return; 36 + if (identity.status !== "unchecked") return; 37 + void useAuthStore.getState().checkIdentity(); 38 + }, [identity.status, childRouteActive]); 39 + 40 + if (childRouteActive) return null; 41 + 42 + const isChecking = identity.status === "unchecked" || identity.status === "checking"; 43 + 44 + if (isChecking || !minTimeElapsed) { 45 + return <CheckingView />; 46 + } 47 + 48 + switch (identity.status) { 49 + case "fresh": 50 + return <FreshAccountView />; 51 + case "remote_only": 52 + return <RecoverIdentityView />; 53 + case "conflict": 54 + return <ConflictView />; 55 + case "ready": 56 + return <ReadyView />; 57 + } 58 + } 59 + 60 + function ErrorView() { 61 + return ( 62 + <div className="flex flex-col items-center gap-6 text-center"> 63 + <PageHeader icon={WarningIcon} iconClassName="text-error" title="Something went wrong" /> 64 + <a href="/devices" className="btn btn-neutral btn-sm"> 65 + Back to devices 66 + </a> 67 + </div> 68 + ); 69 + }
+53
web/src/routes/devices/login.tsx
··· 1 + import { useState } from "react" 2 + import { createFileRoute, redirect } from "@tanstack/react-router" 3 + import { useAuthStore } from "@/stores/auth" 4 + 5 + function LoginPage() { 6 + const session = useAuthStore((s) => s.session) 7 + const startLogin = useAuthStore((s) => s.startLogin) 8 + const [handle, setHandle] = useState("") 9 + const isLoading = session.status === "authenticating" 10 + const errorMessage = session.status === "error" ? session.message : null 11 + 12 + const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => { 13 + e.preventDefault() 14 + if (!handle.trim()) return 15 + void startLogin(handle.trim()) 16 + } 17 + 18 + return ( 19 + <form onSubmit={handleSubmit} className="card card-bordered bg-base-100 w-80 p-6"> 20 + <h1 className="text-ui text-base-content mb-1 font-medium">Sign in to Opake</h1> 21 + <p className="text-caption text-text-muted mb-5"> 22 + Enter your AT Protocol handle to continue. 23 + </p> 24 + <label className="input input-bordered mb-3 flex items-center gap-2"> 25 + <input 26 + type="text" 27 + placeholder="you.bsky.social" 28 + value={handle} 29 + onChange={(e) => setHandle(e.target.value)} 30 + className="grow" 31 + required 32 + disabled={isLoading} 33 + aria-label="AT Protocol handle" 34 + /> 35 + </label> 36 + {errorMessage && ( 37 + <p className="text-caption text-error mb-3" role="alert"> 38 + {errorMessage} 39 + </p> 40 + )} 41 + <button type="submit" className="btn btn-neutral w-full" disabled={isLoading}> 42 + {isLoading ? <span className="loading loading-spinner loading-sm" /> : "Sign in"} 43 + </button> 44 + </form> 45 + ) 46 + } 47 + 48 + export const Route = createFileRoute("/devices/login")({ 49 + beforeLoad: ({ context }) => { 50 + if (context.auth.session.status === "active") throw redirect({ to: "/devices" }) 51 + }, 52 + component: LoginPage, 53 + })
+82
web/src/routes/devices/oauth-callback.tsx
··· 1 + import { useEffect, useRef } from "react" 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" 3 + import { useAuthStore } from "@/stores/auth" 4 + 5 + function OAuthCallbackPage() { 6 + const navigate = useNavigate() 7 + const session = useAuthStore((s) => s.session) 8 + const completeLogin = useAuthStore((s) => s.completeLogin) 9 + const errorMessage = session.status === "error" ? session.message : null 10 + 11 + const hasStartedRef = useRef(false) 12 + 13 + useEffect(() => { 14 + if (hasStartedRef.current) return 15 + hasStartedRef.current = true 16 + 17 + const params = new URLSearchParams(window.location.search) 18 + const code = params.get("code") 19 + const state = params.get("state") 20 + 21 + window.history.replaceState({}, "", window.location.pathname) 22 + 23 + if (!code || !state) { 24 + useAuthStore.setState((draft) => { 25 + draft.session = { status: "error", message: "Missing authorization code or state parameter." } 26 + }) 27 + return 28 + } 29 + 30 + void completeLogin(code, state) 31 + }, [completeLogin]) 32 + 33 + useEffect(() => { 34 + if (session.status === "active") { 35 + void navigate({ to: "/devices" }) 36 + } 37 + }, [session.status, navigate]) 38 + 39 + return ( 40 + <> 41 + {session.status !== "error" && ( 42 + <div className="flex flex-col items-center gap-4"> 43 + <span className="loading loading-spinner loading-lg text-primary" /> 44 + <p className="text-base-content/60 text-sm">Completing login…</p> 45 + </div> 46 + )} 47 + 48 + {session.status === "error" && ( 49 + <div className="flex flex-col items-center gap-6 text-center"> 50 + <div className="bg-error/20 flex h-16 w-16 items-center justify-center rounded-full"> 51 + <svg 52 + className="text-error h-8 w-8" 53 + fill="none" 54 + viewBox="0 0 24 24" 55 + stroke="currentColor" 56 + strokeWidth={2} 57 + aria-hidden="true" 58 + > 59 + <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> 60 + </svg> 61 + </div> 62 + 63 + <div className="flex flex-col gap-2"> 64 + <h1 className="text-base-content text-2xl font-semibold">Login failed</h1> 65 + <p className="text-base-content/60">{errorMessage}</p> 66 + </div> 67 + 68 + <a href="/devices/login" className="btn btn-neutral btn-sm mt-2"> 69 + Try again 70 + </a> 71 + </div> 72 + )} 73 + </> 74 + ) 75 + } 76 + 77 + export const Route = createFileRoute("/devices/oauth-callback")({ 78 + beforeLoad: ({ context }) => { 79 + if (context.auth.session.status === "active") throw redirect({ to: "/devices" }) 80 + }, 81 + component: OAuthCallbackPage, 82 + })
+213
web/src/routes/devices/pair.accept.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 + import { useAuthStore } from "@/stores/auth"; 4 + import { getCryptoWorker } from "@/lib/worker"; 5 + import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 6 + import { listPairRequests, approvePairRequest, type PendingPairRequest } from "@/lib/pairing"; 7 + import { CheckCircleIcon, WarningIcon } from "@phosphor-icons/react"; 8 + import { useAppStore } from "@/stores/app"; 9 + 10 + const POLL_INTERVAL_MS = 5000; 11 + const MAX_KEY_AGE_MINUTES = 30; 12 + const MAX_KEY_AGE = 1000 * 60 * MAX_KEY_AGE_MINUTES; 13 + 14 + const storage = new IndexedDbStorage(); 15 + 16 + // --------------------------------------------------------------------------- 17 + // Route 18 + // --------------------------------------------------------------------------- 19 + 20 + export const Route = createFileRoute("/devices/pair/accept")({ 21 + beforeLoad: ({ context }) => { 22 + if (context.auth.session.status !== "active") { 23 + throw redirect({ to: "/devices/login" }); 24 + } 25 + }, 26 + component: PairAcceptPage, 27 + }); 28 + 29 + // --------------------------------------------------------------------------- 30 + // Page — existing device approving a pair request 31 + // --------------------------------------------------------------------------- 32 + 33 + type AcceptState = 34 + | { step: "loading" } 35 + | { step: "empty" } 36 + | { step: "selecting"; requests: readonly PendingPairRequest[] } 37 + | { step: "approving" } 38 + | { step: "success" } 39 + | { step: "error"; message: string }; 40 + 41 + async function fetchPairRequests(): Promise<AcceptState> { 42 + const authState = useAuthStore.getState(); 43 + if (authState.session.status !== "active") return { step: "loading" }; 44 + 45 + const { did, pdsUrl } = authState.session; 46 + const session = await storage.loadSession(did); 47 + 48 + try { 49 + const requests = await listPairRequests(pdsUrl, did, session, MAX_KEY_AGE); 50 + return requests.length === 0 ? { step: "empty" } : { step: "selecting", requests }; 51 + } catch (err) { 52 + return { 53 + step: "error", 54 + message: err instanceof Error ? err.message : String(err), 55 + }; 56 + } 57 + } 58 + 59 + function PairAcceptPage() { 60 + const [state, setState] = useState<AcceptState>({ step: "loading" }); 61 + const { addLoading, removeLoading } = useAppStore(); 62 + const initialLoadDone = useRef(false); 63 + const navigate = useNavigate(); 64 + 65 + useEffect(() => { 66 + if (initialLoadDone.current) return; 67 + initialLoadDone.current = true; 68 + 69 + addLoading("pair-accept-fetch"); 70 + void fetchPairRequests() 71 + .then(setState) 72 + .finally(() => removeLoading("pair-accept-fetch")); 73 + }, [addLoading, removeLoading]); 74 + 75 + const shouldPoll = state.step === "loading" || state.step === "empty" || state.step === "selecting"; 76 + 77 + useEffect(() => { 78 + if (!shouldPoll) return; 79 + 80 + const interval = setInterval(() => { 81 + addLoading("pair-accept-fetch"); 82 + void fetchPairRequests() 83 + .then(setState) 84 + .finally(() => removeLoading("pair-accept-fetch")); 85 + }, POLL_INTERVAL_MS); 86 + 87 + return () => clearInterval(interval); 88 + }, [shouldPoll, addLoading, removeLoading]); 89 + 90 + const handleApprove = useCallback( 91 + async (request: PendingPairRequest) => { 92 + setState({ step: "approving" }); 93 + addLoading("pair-accept-approve"); 94 + 95 + const authState = useAuthStore.getState(); 96 + if (authState.session.status !== "active") { 97 + removeLoading("pair-accept-approve"); 98 + return; 99 + } 100 + 101 + const { did, pdsUrl } = authState.session; 102 + const worker = getCryptoWorker(); 103 + 104 + try { 105 + const session = await storage.loadSession(did); 106 + const identity = await storage.loadIdentity(did); 107 + 108 + await approvePairRequest( 109 + pdsUrl, 110 + did, 111 + request.uri, 112 + request.ephemeralKey, 113 + identity, 114 + session, 115 + worker, 116 + ); 117 + 118 + setState({ step: "success" }); 119 + setTimeout(() => navigate({ to: "/cabinet" }), 1500); 120 + } catch (err) { 121 + setState({ 122 + step: "error", 123 + message: err instanceof Error ? err.message : String(err), 124 + }); 125 + } finally { 126 + removeLoading("pair-accept-approve"); 127 + } 128 + }, 129 + [addLoading, navigate, removeLoading], 130 + ); 131 + 132 + return ( 133 + <div className="flex w-full max-w-md flex-col items-center"> 134 + {state.step === "loading" && ( 135 + <div className="flex flex-col items-center gap-4"> 136 + <span className="loading loading-spinner loading-lg text-primary" /> 137 + <p className="text-base-content/60 text-sm">Loading pair requests…</p> 138 + </div> 139 + )} 140 + 141 + {state.step === "empty" && ( 142 + <div className="flex flex-col items-center gap-6 text-center"> 143 + <h1 className="text-base-content text-2xl font-semibold">No pending requests</h1> 144 + <p className="text-base-content/60 text-center text-sm"> 145 + Start a pairing request from your new device first, then come back here to approve it. 146 + Only requests made in the last {MAX_KEY_AGE_MINUTES} minutes are shown. 147 + </p> 148 + </div> 149 + )} 150 + 151 + {state.step === "selecting" && ( 152 + <div className="flex flex-col items-center gap-6"> 153 + <h1 className="text-base-content text-2xl font-semibold">Approve a device</h1> 154 + <p className="text-base-content/60 text-center text-sm"> 155 + Verify the fingerprint matches what your new device shows. Only requests made in the 156 + last {MAX_KEY_AGE_MINUTES} are shown. 157 + </p> 158 + 159 + <div className="flex w-full max-w-sm flex-col gap-3"> 160 + {state.requests.map((req) => ( 161 + <div key={req.uri} className="card card-bordered bg-base-100 p-4"> 162 + <div className="text-primary mb-2 font-mono text-sm tracking-wider"> 163 + {req.fingerprint} 164 + </div> 165 + <div className="text-base-content/50 mb-3 text-xs"> 166 + {new Date(req.createdAt).toLocaleString()} 167 + </div> 168 + <button 169 + onClick={() => { 170 + void handleApprove(req); 171 + }} 172 + className="btn btn-neutral btn-sm w-full" 173 + > 174 + Approve 175 + </button> 176 + </div> 177 + ))} 178 + </div> 179 + </div> 180 + )} 181 + 182 + {state.step === "approving" && ( 183 + <div className="flex flex-col items-center gap-4"> 184 + <span className="loading loading-spinner loading-lg text-primary" /> 185 + <p className="text-base-content/60 text-sm">Encrypting and sending identity…</p> 186 + </div> 187 + )} 188 + 189 + {state.step === "success" && ( 190 + <div className="flex flex-col items-center gap-4"> 191 + <CheckCircleIcon size={48} className="text-success" weight="fill" /> 192 + <p className="text-base-content text-lg font-medium">Approved</p> 193 + <p className="text-base-content/60 text-sm"> 194 + The other device should receive your identity shortly. 195 + </p> 196 + </div> 197 + )} 198 + 199 + {state.step === "error" && ( 200 + <div className="flex flex-col items-center gap-6 text-center"> 201 + <WarningIcon size={48} className="text-error" weight="fill" /> 202 + <div className="flex flex-col gap-2"> 203 + <h1 className="text-base-content text-2xl font-semibold">Pairing failed</h1> 204 + <p className="text-base-content/60">{state.message}</p> 205 + </div> 206 + <a href="/devices" className="btn btn-neutral btn-sm"> 207 + Try again 208 + </a> 209 + </div> 210 + )} 211 + </div> 212 + ); 213 + }
+197
web/src/routes/devices/pair.request.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 + import { useAuthStore } from "@/stores/auth"; 4 + import { getCryptoWorker } from "@/lib/worker"; 5 + import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 6 + import { formatFingerprint, rkeyFromUri } from "@/lib/encoding"; 7 + import { 8 + createPairRequest, 9 + pollForPairResponse, 10 + receivePairResponse, 11 + cleanupPairRecords, 12 + } from "@/lib/pairing"; 13 + import { CheckCircleIcon, WarningIcon } from "@phosphor-icons/react"; 14 + import { useAppStore } from "@/stores/app"; 15 + 16 + const POLL_INTERVAL_MS = 3000; 17 + 18 + const storage = new IndexedDbStorage(); 19 + 20 + // --------------------------------------------------------------------------- 21 + // Route 22 + // --------------------------------------------------------------------------- 23 + 24 + export const Route = createFileRoute("/devices/pair/request")({ 25 + beforeLoad: ({ context }) => { 26 + if (context.auth.session.status !== "active") { 27 + throw redirect({ to: "/devices/login" }); 28 + } 29 + }, 30 + component: PairRequestPage, 31 + }); 32 + 33 + // --------------------------------------------------------------------------- 34 + // Page — new device requesting identity from an existing device 35 + // --------------------------------------------------------------------------- 36 + 37 + type RequestState = 38 + | { step: "generating" } 39 + | { step: "waiting"; fingerprint: string; requestUri: string } 40 + | { step: "receiving" } 41 + | { step: "success" } 42 + | { step: "error"; message: string }; 43 + 44 + function PairRequestPage() { 45 + const navigate = useNavigate(); 46 + const [state, setState] = useState<RequestState>({ step: "generating" }); 47 + const { addLoading, removeLoading } = useAppStore(); 48 + const ephemeralPrivKeyRef = useRef<Uint8Array | null>(null); 49 + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); 50 + 51 + const cleanup = useCallback(() => { 52 + if (pollRef.current) { 53 + clearInterval(pollRef.current); 54 + pollRef.current = null; 55 + } 56 + }, []); 57 + 58 + useEffect(() => { 59 + const cancelledRef = { current: false }; 60 + 61 + async function init() { 62 + const authState = useAuthStore.getState(); 63 + if (authState.session.status !== "active") return; 64 + 65 + const { did, pdsUrl } = authState.session; 66 + const worker = getCryptoWorker(); 67 + const session = await storage.loadSession(did); 68 + 69 + addLoading("pair-request-init"); 70 + try { 71 + const ephemeral = await worker.generateEphemeralKeypair(); 72 + ephemeralPrivKeyRef.current = ephemeral.privateKey; 73 + 74 + const requestUri = await createPairRequest(pdsUrl, did, ephemeral.publicKey, session); 75 + 76 + if (cancelledRef.current) return; 77 + 78 + const fingerprint = formatFingerprint(ephemeral.publicKey); 79 + const requestRkey = rkeyFromUri(requestUri); 80 + setState({ step: "waiting", fingerprint, requestUri }); 81 + 82 + pollRef.current = setInterval(async () => { 83 + try { 84 + const response = await pollForPairResponse(pdsUrl, did, requestRkey, session); 85 + if (!response || cancelledRef.current) return; 86 + 87 + cleanup(); 88 + setState({ step: "receiving" }); 89 + addLoading("pair-request-receive"); 90 + 91 + const privKey = ephemeralPrivKeyRef.current; 92 + if (!privKey) { 93 + setState({ step: "error", message: "Ephemeral private key unavailable" }); 94 + removeLoading("pair-request-receive"); 95 + return; 96 + } 97 + 98 + const identity = await receivePairResponse(response, privKey, worker); 99 + await storage.saveIdentity(did, identity); 100 + 101 + // Clean up PDS records (best-effort) 102 + await cleanupPairRecords(pdsUrl, did, requestUri, null, session).catch( 103 + Function.prototype as () => void, 104 + ); 105 + 106 + // Transition identity to ready 107 + useAuthStore.setState((draft) => { 108 + draft.identity = { status: "ready" }; 109 + }); 110 + 111 + setState({ step: "success" }); 112 + removeLoading("pair-request-receive"); 113 + setTimeout(() => navigate({ to: "/devices" }), 1500); 114 + } catch (err) { 115 + cleanup(); 116 + removeLoading("pair-request-receive"); 117 + setState({ 118 + step: "error", 119 + message: err instanceof Error ? err.message : String(err), 120 + }); 121 + } 122 + }, POLL_INTERVAL_MS); 123 + } catch (err) { 124 + if (cancelledRef.current) return; 125 + setState({ 126 + step: "error", 127 + message: err instanceof Error ? err.message : String(err), 128 + }); 129 + } finally { 130 + removeLoading("pair-request-init"); 131 + } 132 + } 133 + 134 + void init(); 135 + return () => { 136 + cancelledRef.current = true; 137 + cleanup(); 138 + }; 139 + }, [cleanup, navigate, addLoading, removeLoading]); 140 + 141 + return ( 142 + <div className="flex w-full max-w-md flex-col items-center"> 143 + {state.step === "generating" && ( 144 + <div className="flex flex-col items-center gap-4"> 145 + <span className="loading loading-spinner loading-lg text-primary" /> 146 + <p className="text-base-content/60 text-sm">Generating keypair…</p> 147 + </div> 148 + )} 149 + 150 + {state.step === "waiting" && ( 151 + <div className="flex flex-col items-center gap-6 text-center"> 152 + <h1 className="text-base-content text-2xl font-semibold">Pair this device</h1> 153 + <p className="text-base-content/60 text-sm"> 154 + Approve this request from your existing device. Verify the fingerprint matches. 155 + </p> 156 + 157 + <div className="bg-base-100 text-primary rounded-lg px-6 py-4 font-mono text-lg tracking-wider"> 158 + {state.fingerprint} 159 + </div> 160 + 161 + <div className="text-base-content/50 flex items-center gap-2 text-sm"> 162 + <span className="loading loading-spinner loading-xs" /> 163 + Waiting for approval… 164 + </div> 165 + </div> 166 + )} 167 + 168 + {state.step === "receiving" && ( 169 + <div className="flex flex-col items-center gap-4"> 170 + <span className="loading loading-spinner loading-lg text-primary" /> 171 + <p className="text-base-content/60 text-sm">Receiving identity…</p> 172 + </div> 173 + )} 174 + 175 + {state.step === "success" && ( 176 + <div className="flex flex-col items-center gap-4"> 177 + <CheckCircleIcon size={48} className="text-success" weight="fill" /> 178 + <p className="text-base-content text-lg font-medium">Device paired</p> 179 + <p className="text-base-content/60 text-sm">Redirecting…</p> 180 + </div> 181 + )} 182 + 183 + {state.step === "error" && ( 184 + <div className="flex flex-col items-center gap-6 text-center"> 185 + <WarningIcon size={48} className="text-error" weight="fill" /> 186 + <div className="flex flex-col gap-2"> 187 + <h1 className="text-base-content text-2xl font-semibold">Pairing failed</h1> 188 + <p className="text-base-content/60">{state.message}</p> 189 + </div> 190 + <a href="/devices" className="btn btn-neutral btn-sm"> 191 + Try again 192 + </a> 193 + </div> 194 + )} 195 + </div> 196 + ); 197 + }
+46
web/src/routes/devices/route.tsx
··· 1 + import { OpakeLogo } from "@/components/OpakeLogo"; 2 + import { useAppStore } from "@/stores/app"; 3 + import { useAuthStore } from "@/stores/auth"; 4 + import { useMinimumDuration } from "@/utils"; 5 + import { WarningIcon } from "@phosphor-icons/react"; 6 + import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; 7 + 8 + const MIN_CHECK_DISPLAY_MS = 2000; 9 + 10 + export const Route = createFileRoute("/devices")({ 11 + component: View, 12 + errorComponent: ErrorView, 13 + }); 14 + 15 + function View() { 16 + const { anythingLoading } = useAppStore(); 17 + const matchRoute = useMatchRoute(); 18 + const identity = useAuthStore((s) => s.identity); 19 + const minTimeElapsed = useMinimumDuration(MIN_CHECK_DISPLAY_MS); 20 + const isIndex = !!matchRoute({ to: "/devices" }); 21 + const isChecking = identity.status === "unchecked" || identity.status === "checking"; 22 + const showLogo = !isIndex || (!isChecking && minTimeElapsed); 23 + 24 + return ( 25 + <div className="bg-base-300 flex min-h-screen justify-center font-sans sm:pt-32"> 26 + <div className="flex w-full flex-col items-center gap-8 px-6 py-12"> 27 + {showLogo && <OpakeLogo size="xl" loading={anythingLoading()} />} 28 + <Outlet /> 29 + </div> 30 + </div> 31 + ); 32 + } 33 + 34 + function ErrorView() { 35 + return ( 36 + <div className="flex flex-col items-center gap-6 text-center"> 37 + <WarningIcon size={48} className="text-error" weight="fill" /> 38 + <div className="flex flex-col gap-2"> 39 + <h1 className="text-base-content text-2xl font-semibold">Something went wrong</h1> 40 + </div> 41 + <a href="/devices" className="btn btn-neutral btn-sm"> 42 + Back to devices 43 + </a> 44 + </div> 45 + ); 46 + }
-61
web/src/routes/login.tsx
··· 1 - import { useState } from "react" 2 - import { createFileRoute, redirect } from "@tanstack/react-router" 3 - import { OpakeLogo } from "@/components/OpakeLogo" 4 - import { useAuthStore } from "@/stores/auth" 5 - 6 - function LoginPage() { 7 - const phase = useAuthStore((s) => s.phase) 8 - const startLogin = useAuthStore((s) => s.startLogin) 9 - const [handle, setHandle] = useState("") 10 - const isLoading = phase === "authenticating" 11 - const errorMessage = phase === "error" ? useAuthStore.getState() : null 12 - 13 - const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => { 14 - e.preventDefault() 15 - if (!handle.trim()) return 16 - void startLogin(handle.trim()) 17 - // startLogin redirects to the AS — we won't reach here unless it errors 18 - } 19 - 20 - return ( 21 - <div className="bg-base-300 flex min-h-screen flex-col items-center justify-center font-sans"> 22 - <div className="mb-8"> 23 - <OpakeLogo size="lg" /> 24 - </div> 25 - <form onSubmit={handleSubmit} className="card card-bordered bg-base-100 w-80 p-6"> 26 - <h1 className="text-ui text-base-content mb-1 font-medium">Sign in to Opake</h1> 27 - <p className="text-caption text-text-muted mb-5"> 28 - Enter your AT Protocol handle to continue. 29 - </p> 30 - <label className="input input-bordered mb-3 flex items-center gap-2"> 31 - <input 32 - type="text" 33 - placeholder="you.bsky.social" 34 - value={handle} 35 - onChange={(e) => setHandle(e.target.value)} 36 - className="grow" 37 - required 38 - disabled={isLoading} 39 - aria-label="AT Protocol handle" 40 - /> 41 - </label> 42 - {phase === "error" && errorMessage && ( 43 - <p className="text-caption text-error mb-3" role="alert"> 44 - {"message" in errorMessage ? errorMessage.message : "Login failed"} 45 - </p> 46 - )} 47 - <button type="submit" className="btn btn-neutral w-full" disabled={isLoading}> 48 - {isLoading ? <span className="loading loading-spinner loading-sm" /> : "Sign in"} 49 - </button> 50 - </form> 51 - </div> 52 - ) 53 - } 54 - 55 - export const Route = createFileRoute("/login")({ 56 - beforeLoad: () => { 57 - const state = useAuthStore.getState() 58 - if (state.phase === "ready") throw redirect({ to: "/cabinet" }) 59 - }, 60 - component: LoginPage, 61 - })
-95
web/src/routes/oauth.callback.tsx
··· 1 - import { useEffect, useRef } from "react" 2 - import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" 3 - import { OpakeLogo } from "@/components/OpakeLogo" 4 - import { useAuthStore } from "@/stores/auth" 5 - 6 - function OAuthCallbackPage() { 7 - const navigate = useNavigate() 8 - const phase = useAuthStore((s) => s.phase) 9 - const completeLogin = useAuthStore((s) => s.completeLogin) 10 - const errorMessage = 11 - phase === "error" ? (useAuthStore.getState() as { message: string }).message : null 12 - 13 - const hasStartedRef = useRef(false) 14 - 15 - useEffect(() => { 16 - if (hasStartedRef.current) return 17 - hasStartedRef.current = true 18 - 19 - const params = new URLSearchParams(window.location.search) 20 - const code = params.get("code") 21 - const state = params.get("state") 22 - 23 - // Strip query params from URL immediately 24 - window.history.replaceState({}, "", window.location.pathname) 25 - 26 - if (!code || !state) { 27 - useAuthStore.setState({ 28 - phase: "error", 29 - message: "Missing authorization code or state parameter.", 30 - }) 31 - return 32 - } 33 - 34 - void completeLogin(code, state) 35 - }, [completeLogin]) 36 - 37 - useEffect(() => { 38 - if (phase === "ready") { 39 - void navigate({ to: "/cabinet" }) 40 - } else if (phase === "awaiting_identity") { 41 - void navigate({ to: "/cabinet/devices" }) 42 - } 43 - }, [phase, navigate]) 44 - 45 - return ( 46 - <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 47 - <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 48 - <OpakeLogo size="lg" /> 49 - 50 - {(phase === "authenticating" || 51 - phase === "initializing" || 52 - phase === "unauthenticated") && ( 53 - <div className="flex flex-col items-center gap-4"> 54 - <span className="loading loading-spinner loading-lg text-primary" /> 55 - <p className="text-base-content/60 text-sm">Completing login…</p> 56 - </div> 57 - )} 58 - 59 - {phase === "error" && ( 60 - <div className="flex flex-col items-center gap-6 text-center"> 61 - <div className="bg-error/20 flex h-16 w-16 items-center justify-center rounded-full"> 62 - <svg 63 - className="text-error h-8 w-8" 64 - fill="none" 65 - viewBox="0 0 24 24" 66 - stroke="currentColor" 67 - strokeWidth={2} 68 - aria-hidden="true" 69 - > 70 - <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> 71 - </svg> 72 - </div> 73 - 74 - <div className="flex flex-col gap-2"> 75 - <h1 className="text-base-content text-2xl font-semibold">Login failed</h1> 76 - <p className="text-base-content/60">{errorMessage}</p> 77 - </div> 78 - 79 - <a href="/login" className="btn btn-neutral btn-sm mt-2"> 80 - Try again 81 - </a> 82 - </div> 83 - )} 84 - </div> 85 - </div> 86 - ) 87 - } 88 - 89 - export const Route = createFileRoute("/oauth/callback")({ 90 - beforeLoad: () => { 91 - const state = useAuthStore.getState() 92 - if (state.phase === "ready") throw redirect({ to: "/cabinet" }) 93 - }, 94 - component: OAuthCallbackPage, 95 - })
-81
web/src/routes/oauth.cli-callback.tsx
··· 1 - import { CheckIcon } from "@phosphor-icons/react" 2 - import { useMemo, useEffect } from "react" 3 - import { createFileRoute } from "@tanstack/react-router" 4 - import { OpakeLogo } from "@/components/OpakeLogo" 5 - 6 - type CallbackResult = 7 - | { state: "success"; errorMessage: "" } 8 - | { state: "error"; errorMessage: string } 9 - 10 - function OAuthCallbackPage() { 11 - const { state, errorMessage } = useMemo<CallbackResult>(() => { 12 - const params = new URLSearchParams(window.location.search) 13 - const error = params.get("error") 14 - if (error) { 15 - return { state: "error", errorMessage: error } 16 - } 17 - return { state: "success", errorMessage: "" } 18 - }, []) 19 - 20 - useEffect(() => { 21 - // Strip query params from URL after reading them 22 - window.history.replaceState({}, "", window.location.pathname) 23 - }, []) 24 - 25 - return ( 26 - <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 27 - <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 28 - <OpakeLogo size="lg" /> 29 - 30 - {state === "success" && ( 31 - <div className="flex flex-col items-center gap-6 text-center"> 32 - <div className="bg-success/20 flex h-16 w-16 items-center justify-center rounded-full"> 33 - <CheckIcon size={32} /> 34 - </div> 35 - 36 - <div className="flex flex-col gap-2"> 37 - <h1 className="text-base-content text-2xl font-semibold">CLI login successful</h1> 38 - </div> 39 - 40 - <div className="text-base-content/50 mt-2 flex flex-col gap-3 text-sm"> 41 - <p>You can close this tab and return to your terminal.</p> 42 - <p> 43 - <span className="text-base-content/70 font-medium">Note:</span> This logs you into 44 - the CLI only. The web app requires a separate login. 45 - </p> 46 - </div> 47 - </div> 48 - )} 49 - 50 - {state === "error" && ( 51 - <div className="flex flex-col items-center gap-6 text-center"> 52 - <div className="bg-error/20 flex h-16 w-16 items-center justify-center rounded-full"> 53 - <svg 54 - className="text-error h-8 w-8" 55 - fill="none" 56 - viewBox="0 0 24 24" 57 - stroke="currentColor" 58 - strokeWidth={2} 59 - > 60 - <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> 61 - </svg> 62 - </div> 63 - 64 - <div className="flex flex-col gap-2"> 65 - <h1 className="text-base-content text-2xl font-semibold">CLI login failed</h1> 66 - <p className="text-base-content/60">{errorMessage}</p> 67 - </div> 68 - 69 - <div className="text-base-content/50 mt-2 flex flex-col gap-3 text-sm"> 70 - <p>Please try again from your terminal.</p> 71 - </div> 72 - </div> 73 - )} 74 - </div> 75 - </div> 76 - ) 77 - } 78 - 79 - export const Route = createFileRoute("/oauth/cli-callback")({ 80 - component: OAuthCallbackPage, 81 - })
+39
web/src/stores/app.ts
··· 1 + import { create } from "zustand"; 2 + import { immer } from "zustand/middleware/immer"; 3 + 4 + // --------------------------------------------------------------------------- 5 + // State 6 + // --------------------------------------------------------------------------- 7 + 8 + interface AppState { 9 + loadingItems: Set<string>; 10 + } 11 + 12 + interface AppActions { 13 + addLoading(what: string): void; 14 + removeLoading(what: string): void; 15 + isLoading(what: string): boolean; 16 + anythingLoading(): boolean; 17 + } 18 + 19 + type AppStore = AppState & AppActions; 20 + 21 + // --------------------------------------------------------------------------- 22 + // Store 23 + // --------------------------------------------------------------------------- 24 + 25 + export const useAppStore = create<AppStore>()( 26 + immer((set, get) => ({ 27 + loadingItems: new Set(), 28 + addLoading: (what) => 29 + set((draft) => { 30 + draft.loadingItems.add(what); 31 + }), 32 + removeLoading: (what) => 33 + set((draft) => { 34 + draft.loadingItems.delete(what); 35 + }), 36 + isLoading: (what) => get().loadingItems.has(what), 37 + anythingLoading: () => !!get().loadingItems.size, 38 + })), 39 + );
+238 -152
web/src/stores/auth.ts
··· 1 - // Auth store — real OAuth 2.0 + DPoP flow via Zustand. 1 + // Auth store — OAuth 2.0 + DPoP + identity resolution via Zustand. 2 + // 3 + // Two independent state dimensions: 4 + // Session: none | authenticating | active 5 + // Identity: unchecked | checking | fresh | remote_only | conflict | ready 2 6 // 3 - // State machine: initializing → unauthenticated ↔ authenticating → ready 4 - // ↘ awaiting_identity 5 - // ↘ error 7 + // Session determines "can we talk to the PDS?" 8 + // Identity determines "can we encrypt/decrypt?" 6 9 7 - import { create } from "zustand" 8 - import { immer } from "zustand/middleware/immer" 9 - import type { OAuthSession, Config } from "@/lib/storage-types" 10 - import { IndexedDbStorage } from "@/lib/indexeddb-storage" 11 - import { getCryptoWorker } from "@/lib/worker" 12 - import { authenticatedXrpc } from "@/lib/api" 10 + import { create } from "zustand"; 11 + import { immer } from "zustand/middleware/immer"; 12 + import type { OAuthSession, Config } from "@/lib/storage-types"; 13 + import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 14 + import { getCryptoWorker } from "@/lib/worker"; 15 + import { authenticatedXrpc } from "@/lib/api"; 16 + import { useAppStore } from "@/stores/app"; 13 17 import { 14 18 resolveHandleToPds, 15 19 discoverAuthorizationServer, ··· 23 27 loadPendingState, 24 28 clearPendingState, 25 29 generateCsrfState, 26 - } from "@/lib/oauth" 30 + } from "@/lib/oauth"; 27 31 28 32 // --------------------------------------------------------------------------- 29 33 // State types 30 34 // --------------------------------------------------------------------------- 31 35 32 - type AuthPhase = 33 - | { phase: "initializing" } 34 - | { phase: "unauthenticated" } 35 - | { phase: "authenticating" } 36 - | { phase: "ready"; did: string; handle: string; pdsUrl: string } 37 - | { phase: "awaiting_identity"; did: string; handle: string; pdsUrl: string } 38 - | { phase: "error"; message: string } 36 + export type SessionState = 37 + | { status: "initializing" } 38 + | { status: "none" } 39 + | { status: "authenticating" } 40 + | { status: "active"; did: string; handle: string; pdsUrl: string } 41 + | { status: "error"; message: string }; 42 + 43 + export type IdentityState = 44 + | { status: "unchecked" } 45 + | { status: "checking" } 46 + | { status: "fresh" } 47 + | { status: "remote_only" } 48 + | { status: "conflict" } 49 + | { status: "ready" }; 39 50 40 51 interface AuthActions { 41 - boot(): Promise<void> 42 - startLogin(handle: string): Promise<void> 43 - completeLogin(code: string, state: string): Promise<void> 44 - logout(): Promise<void> 52 + boot(): Promise<void>; 53 + startLogin(handle: string): Promise<void>; 54 + completeLogin(code: string, state: string): Promise<void>; 55 + checkIdentity(): Promise<void>; 56 + generateAndPublishIdentity(): Promise<void>; 57 + logout(): Promise<void>; 58 + } 59 + 60 + interface AuthState extends AuthActions { 61 + session: SessionState; 62 + identity: IdentityState; 45 63 } 46 64 47 - type AuthState = AuthPhase & AuthActions 65 + // Re-export a combined snapshot for router context 66 + export interface AuthSnapshot { 67 + session: SessionState; 68 + identity: IdentityState; 69 + } 48 70 49 71 // --------------------------------------------------------------------------- 50 72 // Singletons (created once, shared across store actions) 51 73 // --------------------------------------------------------------------------- 52 74 53 - const storage = new IndexedDbStorage() 75 + const storage = new IndexedDbStorage(); 76 + 77 + function loading(key: string) { 78 + useAppStore.getState().addLoading(key); 79 + return () => useAppStore.getState().removeLoading(key); 80 + } 54 81 55 82 // --------------------------------------------------------------------------- 56 83 // Helpers 57 84 // --------------------------------------------------------------------------- 58 85 59 - /** Check if publicKey/self already exists on the PDS (i.e. another device published it). */ 60 - async function checkExistingPublicKey( 86 + /** Check if publicKey/self already exists on the PDS. */ 87 + async function fetchUpstreamPublicKey( 61 88 pdsUrl: string, 62 89 did: string, 63 90 session: OAuthSession, 64 - ): Promise<boolean> { 91 + ): Promise<string | null> { 65 92 try { 66 - await authenticatedXrpc( 93 + const response = await authenticatedXrpc( 67 94 { 68 95 pdsUrl, 69 96 lexicon: `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.opake.publicKey&rkey=self`, 70 97 method: "GET", 71 98 }, 72 99 session, 73 - ) 74 - return true 100 + ); 101 + // atproto encodes byte fields as { $bytes: "<base64>" } 102 + const record = (response as { value?: { publicKey?: { $bytes: string } | string } }).value; 103 + const raw = record?.publicKey; 104 + if (raw == null) return null; 105 + return typeof raw === "string" ? raw : raw.$bytes; 75 106 } catch (error) { 76 - console.warn("[auth] publicKey/self lookup failed (treating as new account):", error) 77 - return false 107 + console.warn("[auth] publicKey/self lookup failed (treating as absent):", error); 108 + return null; 78 109 } 79 110 } 80 111 ··· 84 115 85 116 export const useAuthStore = create<AuthState>()( 86 117 immer((set, get) => ({ 87 - phase: "initializing", 118 + session: { status: "initializing" }, 119 + identity: { status: "unchecked" }, 88 120 89 121 boot: async () => { 122 + const done = loading("boot"); 123 + 90 124 try { 91 - const config = await storage.loadConfig() 125 + const config = await storage.loadConfig(); 92 126 if (!config.defaultDid) { 93 - set({ phase: "unauthenticated" }) 94 - return 127 + set((draft) => { 128 + draft.session = { status: "none" }; 129 + }); 130 + return; 95 131 } 96 132 97 - const did = config.defaultDid 133 + const did = config.defaultDid; 98 134 const account = config.accounts[did] as 99 135 | import("@/lib/storage-types").AccountConfig 100 - | undefined 136 + | undefined; 101 137 if (!account) { 102 - set({ phase: "unauthenticated" }) 103 - return 138 + set((draft) => { 139 + draft.session = { status: "none" }; 140 + }); 141 + return; 104 142 } 105 143 106 144 // Verify session exists 107 - await storage.loadSession(did) 145 + await storage.loadSession(did); 146 + 147 + set((draft) => { 148 + draft.session = { status: "active", did, handle: account.handle, pdsUrl: account.pdsUrl }; 149 + }); 150 + } catch { 151 + set((draft) => { 152 + draft.session = { status: "none" }; 153 + }); 154 + } finally { 155 + done(); 156 + } 157 + }, 158 + 159 + checkIdentity: async () => { 160 + const { session } = get(); 161 + if (session.status !== "active") return; 162 + 163 + const done = loading("identity-check"); 164 + set((draft) => { 165 + draft.identity = { status: "checking" }; 166 + }); 167 + 168 + try { 169 + const oauthSession = await storage.loadSession(session.did); 170 + 171 + const [localIdentity, upstreamKey] = await Promise.all([ 172 + storage.loadIdentity(session.did).catch(() => null), 173 + fetchUpstreamPublicKey(session.pdsUrl, session.did, oauthSession as OAuthSession), 174 + ]); 108 175 109 - // Check if identity exists locally 110 - const hasIdentity = await storage 111 - .loadIdentity(did) 112 - .then(() => true) 113 - .catch(() => false) 176 + const hasLocal = localIdentity !== null; 177 + const hasUpstream = upstreamKey !== null; 114 178 115 - if (hasIdentity) { 116 - set({ phase: "ready", did, handle: account.handle, pdsUrl: account.pdsUrl }) 179 + if (!hasLocal && !hasUpstream) { 180 + set((draft) => { 181 + draft.identity = { status: "fresh" }; 182 + }); 183 + } else if (!hasLocal && hasUpstream) { 184 + set((draft) => { 185 + draft.identity = { status: "remote_only" }; 186 + }); 187 + } else if (hasLocal && hasUpstream && localIdentity.public_key !== upstreamKey) { 188 + set((draft) => { 189 + draft.identity = { status: "conflict" }; 190 + }); 117 191 } else { 118 - set({ phase: "awaiting_identity", did, handle: account.handle, pdsUrl: account.pdsUrl }) 192 + set((draft) => { 193 + draft.identity = { status: "ready" }; 194 + }); 119 195 } 120 - } catch { 121 - set({ phase: "unauthenticated" }) 196 + } catch (error) { 197 + console.error("[auth] checkIdentity failed:", error); 198 + set((draft) => { 199 + draft.identity = { status: "unchecked" }; 200 + }); 201 + } finally { 202 + done(); 122 203 } 123 204 }, 124 205 125 - startLogin: async (handle: string) => { 126 - set({ phase: "authenticating" }) 127 - const worker = getCryptoWorker() 206 + generateAndPublishIdentity: async () => { 207 + const { session } = get(); 208 + if (session.status !== "active") return; 209 + 210 + const done = loading("generate-identity"); 211 + set((draft) => { 212 + draft.identity = { status: "checking" }; 213 + }); 214 + const worker = getCryptoWorker(); 128 215 129 216 try { 130 - // Resolve handle → PDS 131 - const { pdsUrl } = await resolveHandleToPds(handle) 217 + const oauthSession = (await storage.loadSession(session.did)) as OAuthSession; 218 + const identity = await worker.generateIdentity(session.did); 219 + 220 + await publishPublicKey( 221 + session.pdsUrl, 222 + session.did, 223 + identity.public_key, 224 + identity.verify_key, 225 + oauthSession.accessToken, 226 + oauthSession.dpopKey, 227 + oauthSession.dpopNonce, 228 + worker, 229 + ); 132 230 133 - // OAuth discovery 134 - const asm = await discoverAuthorizationServer(pdsUrl) 231 + await storage.saveIdentity(session.did, identity); 135 232 136 - // Generate crypto material 137 - const dpopKey = await worker.generateDpopKeyPair() 138 - const pkce = await worker.generatePkce() 139 - const csrfState = generateCsrfState() 233 + set((draft) => { 234 + draft.identity = { status: "ready" }; 235 + }); 236 + } catch (error) { 237 + console.error("[auth] generateAndPublishIdentity failed:", error); 238 + set((draft) => { 239 + draft.identity = { status: "fresh" }; 240 + }); 241 + } finally { 242 + done(); 243 + } 244 + }, 140 245 141 - // Client ID + redirect URI 142 - const redirectUri = buildRedirectUri() 143 - const clientId = buildClientId(redirectUri) 246 + startLogin: async (handle: string) => { 247 + const done = loading("login"); 144 248 145 - const parEndpoint = asm.pushed_authorization_request_endpoint ?? asm.token_endpoint 249 + set((draft) => { 250 + draft.session = { status: "authenticating" }; 251 + }); 252 + const worker = getCryptoWorker(); 146 253 147 - // PAR 254 + try { 255 + const { pdsUrl } = await resolveHandleToPds(handle); 256 + const asm = await discoverAuthorizationServer(pdsUrl); 257 + const dpopKey = await worker.generateDpopKeyPair(); 258 + const pkce = await worker.generatePkce(); 259 + const csrfState = generateCsrfState(); 260 + const redirectUri = buildRedirectUri(); 261 + const clientId = buildClientId(redirectUri); 262 + const parEndpoint = asm.pushed_authorization_request_endpoint ?? asm.token_endpoint; 263 + 148 264 const { requestUri, dpopNonce } = await pushedAuthorizationRequest( 149 265 parEndpoint, 150 266 clientId, ··· 154 270 dpopKey, 155 271 null, 156 272 worker, 157 - ) 273 + ); 158 274 159 - // Persist pre-redirect state 160 275 savePendingState({ 161 276 pdsUrl, 162 277 handle, ··· 166 281 tokenEndpoint: asm.token_endpoint, 167 282 clientId, 168 283 dpopNonce, 169 - }) 284 + }); 170 285 171 - // Redirect to AS 172 - const authUrl = buildAuthorizationUrl(asm.authorization_endpoint, clientId, requestUri) 173 - window.location.href = authUrl 286 + const authUrl = buildAuthorizationUrl(asm.authorization_endpoint, clientId, requestUri); 287 + window.location.href = authUrl; 174 288 } catch (error) { 175 - console.error("[auth] startLogin failed:", error) 176 - const message = error instanceof Error ? error.message : String(error) 177 - set({ phase: "error", message }) 289 + console.error("[auth] startLogin failed:", error); 290 + const message = error instanceof Error ? error.message : String(error); 291 + set((draft) => { 292 + draft.session = { status: "error", message }; 293 + }); 294 + } finally { 295 + done(); 178 296 } 179 297 }, 180 298 181 299 completeLogin: async (code: string, callbackState: string) => { 182 - console.debug("[auth] completeLogin called, current phase:", get().phase) 183 - if (get().phase === "ready") return 184 - set({ phase: "authenticating" }) 185 - const worker = getCryptoWorker() 300 + const { session } = get(); 301 + if (session.status === "active") return; 302 + const done = loading("complete-login"); 303 + set((draft) => { 304 + draft.session = { status: "authenticating" }; 305 + }); 306 + const worker = getCryptoWorker(); 186 307 187 308 try { 188 - const pending = loadPendingState() 189 - console.debug("[auth] pending state:", pending ? "loaded" : "missing") 190 - if (!pending) throw new Error("No pending OAuth state — start login again") 309 + const pending = loadPendingState(); 310 + if (!pending) throw new Error("No pending OAuth state — start login again"); 191 311 192 - // CSRF verification 193 312 if (callbackState !== pending.csrfState) { 194 - clearPendingState() 195 - throw new Error("OAuth state mismatch — possible CSRF attack") 313 + clearPendingState(); 314 + throw new Error("OAuth state mismatch — possible CSRF attack"); 196 315 } 197 316 198 - console.debug("[auth] CSRF ok, exchanging code") 199 - const redirectUri = buildRedirectUri() 317 + const redirectUri = buildRedirectUri(); 200 318 201 - // Exchange code for tokens 202 319 const { tokenResponse, dpopNonce } = await exchangeCode( 203 320 pending.tokenEndpoint, 204 321 pending.clientId, ··· 208 325 pending.dpopKey, 209 326 pending.dpopNonce, 210 327 worker, 211 - ) 328 + ); 212 329 213 - console.debug("[auth] token exchange done, sub:", tokenResponse.sub) 214 - const did = tokenResponse.sub 215 - if (!did) throw new Error("Token response missing `sub` claim") 330 + const did = tokenResponse.sub; 331 + if (!did) throw new Error("Token response missing `sub` claim"); 216 332 217 - const timestamp = Math.floor(Date.now() / 1000) 218 - const expiresAt = tokenResponse.expires_in ? timestamp + tokenResponse.expires_in : null 333 + const timestamp = Math.floor(Date.now() / 1000); 334 + const expiresAt = tokenResponse.expires_in ? timestamp + tokenResponse.expires_in : null; 219 335 220 - const session: Readonly<OAuthSession> = { 336 + const oauthSession: Readonly<OAuthSession> = { 221 337 type: "oauth", 222 338 did, 223 339 handle: pending.handle, ··· 228 344 dpopNonce, 229 345 expiresAt, 230 346 clientId: pending.clientId, 231 - } 232 - 233 - console.debug("[auth] checking existing publicKey/self") 234 - // Check if this DID already has a publicKey/self record on the PDS. 235 - // If so, another device owns the identity — don't overwrite it. 236 - const hasExistingKey = await checkExistingPublicKey(pending.pdsUrl, did, session) 347 + }; 237 348 238 - // Persist config + session regardless 239 349 const existingConfig: Readonly<Config> = await storage.loadConfig().catch(() => ({ 240 350 defaultDid: null, 241 351 accounts: {}, 242 352 appviewUrl: null, 243 - })) 353 + })); 244 354 const config: Readonly<Config> = { 245 355 ...existingConfig, 246 356 defaultDid: did, ··· 248 358 ...existingConfig.accounts, 249 359 [did]: { pdsUrl: pending.pdsUrl, handle: pending.handle }, 250 360 }, 251 - } 252 - 253 - await storage.saveConfig(config) 254 - await storage.saveSession(did, session) 255 - 256 - clearPendingState() 361 + }; 257 362 258 - console.debug("[auth] hasExistingKey:", hasExistingKey) 363 + await storage.saveConfig(config); 364 + await storage.saveSession(did, oauthSession); 365 + clearPendingState(); 259 366 260 - if (hasExistingKey) { 261 - // Identity exists on PDS but not locally — user needs to pair 262 - set({ 263 - phase: "awaiting_identity", 264 - did, 265 - handle: pending.handle, 266 - pdsUrl: pending.pdsUrl, 267 - }) 268 - } else { 269 - // Fresh account — generate identity and publish key 270 - const identity = await worker.generateIdentity(did) 271 - 272 - await publishPublicKey( 273 - pending.pdsUrl, 274 - did, 275 - identity.public_key, 276 - identity.verify_key, 277 - session.accessToken, 278 - session.dpopKey, 279 - session.dpopNonce, 280 - worker, 281 - ) 282 - 283 - await storage.saveIdentity(did, identity) 284 - 285 - set({ 286 - phase: "ready", 287 - did, 288 - handle: pending.handle, 289 - pdsUrl: pending.pdsUrl, 290 - }) 291 - } 367 + set((draft) => { 368 + draft.session = { status: "active", did, handle: pending.handle, pdsUrl: pending.pdsUrl }; 369 + draft.identity = { status: "unchecked" }; 370 + }); 292 371 } catch (error) { 293 - console.error("[auth] completeLogin failed:", error) 294 - clearPendingState() 295 - const message = error instanceof Error ? error.message : String(error) 296 - set({ phase: "error", message }) 372 + console.error("[auth] completeLogin failed:", error); 373 + clearPendingState(); 374 + const message = error instanceof Error ? error.message : String(error); 375 + set((draft) => { 376 + draft.session = { status: "error", message }; 377 + }); 378 + } finally { 379 + done(); 297 380 } 298 381 }, 299 382 300 383 logout: async () => { 301 - const current = get() 302 - if (current.phase === "ready" || current.phase === "awaiting_identity") { 384 + const { session } = get(); 385 + if (session.status === "active") { 303 386 try { 304 - await storage.removeAccount(current.did) 387 + await storage.removeAccount(session.did); 305 388 } catch { 306 389 // best-effort cleanup 307 390 } 308 391 } 309 - set({ phase: "unauthenticated" }) 392 + set((draft) => { 393 + draft.session = { status: "none" }; 394 + draft.identity = { status: "unchecked" }; 395 + }); 310 396 }, 311 397 })), 312 - ) 398 + );
+13
web/src/utils.ts
··· 1 + import { useEffect, useState } from "react"; 2 + 3 + /** Returns `false` until `ms` milliseconds have elapsed since mount. */ 4 + export function useMinimumDuration(ms: number): boolean { 5 + const [elapsed, setElapsed] = useState(false); 6 + 7 + useEffect(() => { 8 + const timer = setTimeout(() => setElapsed(true), ms); 9 + return () => clearTimeout(timer); 10 + }, [ms]); 11 + 12 + return elapsed; 13 + }
+22 -13
web/tests/stores/auth.test.ts
··· 3 3 4 4 describe("auth store", () => { 5 5 beforeEach(() => { 6 - useAuthStore.setState({ phase: "unauthenticated" }); 6 + useAuthStore.setState({ 7 + session: { status: "none" }, 8 + identity: { status: "unchecked" }, 9 + }); 7 10 }); 8 11 9 - it("starts in initializing phase", () => { 10 - useAuthStore.setState({ phase: "initializing" }); 12 + it("starts in initializing session", () => { 13 + useAuthStore.setState({ session: { status: "initializing" } }); 11 14 const state = useAuthStore.getState(); 12 - expect(state.phase).toBe("initializing"); 15 + expect(state.session.status).toBe("initializing"); 13 16 }); 14 17 15 - it("can transition to unauthenticated", () => { 18 + it("can transition to none", () => { 16 19 const state = useAuthStore.getState(); 17 - expect(state.phase).toBe("unauthenticated"); 20 + expect(state.session.status).toBe("none"); 18 21 }); 19 22 20 - it("logout returns to unauthenticated", async () => { 23 + it("logout returns to none session and unchecked identity", async () => { 21 24 useAuthStore.setState({ 22 - phase: "ready", 23 - did: "did:plc:test", 24 - handle: "test.bsky.social", 25 - pdsUrl: "https://pds.test", 25 + session: { 26 + status: "active", 27 + did: "did:plc:test", 28 + handle: "test.bsky.social", 29 + pdsUrl: "https://pds.test", 30 + }, 31 + identity: { status: "ready" }, 26 32 }); 27 33 // logout does best-effort storage cleanup which will fail without 28 34 // IndexedDB, but the state transition should still happen 29 35 await useAuthStore.getState().logout(); 30 - expect(useAuthStore.getState().phase).toBe("unauthenticated"); 36 + expect(useAuthStore.getState().session.status).toBe("none"); 37 + expect(useAuthStore.getState().identity.status).toBe("unchecked"); 31 38 }); 32 39 33 - it("exposes boot, startLogin, completeLogin, logout actions", () => { 40 + it("exposes all action methods", () => { 34 41 const state = useAuthStore.getState(); 35 42 expect(typeof state.boot).toBe("function"); 36 43 expect(typeof state.startLogin).toBe("function"); 37 44 expect(typeof state.completeLogin).toBe("function"); 45 + expect(typeof state.checkIdentity).toBe("function"); 46 + expect(typeof state.generateAndPublishIdentity).toBe("function"); 38 47 expect(typeof state.logout).toBe("function"); 39 48 }); 40 49 });