/** * The Vault browser-extension popup, as a shared component. * * A faithful recreation of `clients/extension` — the real popup's routes, * layout, copy, field labels, sort options and tag/folder structure — running on * sample data that never leaves the device. Interactive: open an item, copy a * field, reveal a TOTP, switch tabs, walk into settings. * * It lives HERE rather than in either consumer because both the marketing site * and the Vault app show it, and two copies of a 1,000-line recreation would * drift the moment the real extension changed. Same reasoning as sharing * `PasswordGenerator` instead of rewriting it per app. * * Two things the website's original did that this deliberately does not: * * * **No `auto` mode.** That drove a synthetic cursor with `querySelector` and * `getBoundingClientRect`, which has no meaning on native. It belongs to that * page's choreography, not to the extension, so the showcase keeps it. * * **No drag-and-drop in the tags tab.** It used `@dnd-kit`, which is DOM-only. * The grip handles remain as affordances because removing them would change * the layout this exists to reproduce, but they do not drag. * * Everything else is the original, moved rather than rewritten. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Image, Linking, Pressable, ScrollView, Text as RNText, TextInput, View, } from "react-native"; import { useColorScheme, vars } from "nativewind"; import { ArrowDownUp, ArrowLeft, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle as CircleIcon, Copy, ExternalLink, Eye, EyeOff, Fingerprint, Folder, FolderPlus, GripVertical, KeyRound, Lock, LogOut, Pencil, Plus, Search, Send, Server, Settings as SettingsIcon, ShieldAlert, SquarePen, Star, Tag as TagIcon, Trash2, } from "lucide-react-native"; import { MutedText, Text } from "../components/text"; import { Button } from "../components/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "../components/card"; /** * The real popup's frame, in px. * * `HEIGHT` is Chrome's *maximum* for a browser action popup, not a fixed size — * the real popup is as tall as it needs to be, up to this. So a host with less * vertical room may render it shorter (see the `height` prop) without the thing * on screen becoming a lie about what the extension looks like; the item list * scrolls inside, exactly as it does at full height. */ export const EXTENSION_POPUP_WIDTH = 380; export const EXTENSION_POPUP_HEIGHT = 600; /** Below this the chrome crowds out the list and it stops reading as the popup. */ export const EXTENSION_POPUP_MIN_HEIGHT = 320; export function useInk() { const { colorScheme: scheme } = useColorScheme(); const dark = scheme === "dark"; // Vault's own palette (pure white in light), matching .vault-surface. return { fg: dark ? "#ffffff" : "#000000", muted: dark ? "#a3a3a3" : "#737373", bg: dark ? "#000000" : "#ffffff", border: dark ? "#262626" : "#e5e5e5", secondary: dark ? "#262626" : "#f5f5f5", danger: "#ef4444", star: "#f59e0b", }; } // Demo graphics are react-native-web, which SSRs markup that then re-lays-out // on hydration ("pops in") and would render with the pre-mount theme guess // (a dark flash on a light page). Gate them to mount client-side, behind a // theme-correct placeholder, so they appear once, already in the right theme. export function vaultVars(dark: boolean) { return vars( dark ? { "--background-rgb": "0 0 0", "--foreground-rgb": "255 255 255", "--card": "#0a0a0a", "--card-foreground": "#ffffff", "--secondary": "#262626", "--secondary-foreground": "#ffffff", "--muted": "#262626", "--muted-foreground": "#a3a3a3", "--border": "#262626", "--primary": "#ffffff", "--primary-foreground": "#000000", } : { "--background-rgb": "255 255 255", "--foreground-rgb": "0 0 0", "--card": "#ffffff", "--card-foreground": "#000000", "--secondary": "#f5f5f5", "--secondary-foreground": "#000000", "--muted": "#f5f5f5", "--muted-foreground": "#737373", "--border": "#e5e5e5", "--primary": "#000000", "--primary-foreground": "#ffffff", } ); } // ── sample data (shape mirrors DecryptedVaultItem.plaintext) ───────────────── export interface Account { username: string; password: string; totp_secret?: string; notes?: string; } // Tags mirror the app's user-created tags: a name, a color, and an icon // (pricetag / circle / star). Rendered as a colored-border chip with the icon. export interface Tag { name: string; color: string; icon?: "tag" | "circle" | "star"; } export interface Item { id: string; name: string; url: string; favorite: boolean; tags: Tag[]; accounts: Account[]; created: string; updated: string; } export const T = { social: { name: "social", color: "#3b82f6", icon: "circle" } as Tag, shopping: { name: "shopping", color: "#22c55e", icon: "tag" } as Tag, finance: { name: "finance", color: "#f59e0b", icon: "star" } as Tag, streaming: { name: "streaming", color: "#a855f7", icon: "circle" } as Tag, gaming: { name: "gaming", color: "#06b6d4", icon: "tag" } as Tag, }; export const ITEMS: Item[] = [ { id: "1", name: "Netflix", url: "https://netflix.com", favorite: true, tags: [T.streaming], created: "2024-02-11", updated: "2025-05-30", accounts: [ { username: "example@entropysoftworks.com", password: "tR0ub4dour&3xplsion", totp_secret: "JBSWY3DPEHPK3PXP", notes: "Shared family plan.", }, ], }, { id: "2", name: "Amazon", url: "https://amazon.com", favorite: true, tags: [T.shopping], created: "2024-01-04", updated: "2025-05-22", accounts: [ { username: "example@entropysoftworks.com", password: "correct-horse-battery", totp_secret: "KRSXG5CTMVRXEZLU", }, ], }, { id: "3", name: "Instagram", url: "https://instagram.com", favorite: true, tags: [T.social], created: "2024-03-02", updated: "2025-06-01", accounts: [ { username: "yourhandle", password: "9xQvM2pL7wE4z", totp_secret: "MFRGGZDFMZTWQ2LK" }, ], }, { id: "4", name: "PayPal", url: "https://paypal.com", favorite: false, tags: [T.finance], created: "2024-04-20", updated: "2025-05-12", accounts: [ { username: "example@entropysoftworks.com", password: "Zx8kLp2qWn5vR", totp_secret: "GEZDGNBVGY3TQOJQ", }, ], }, { id: "5", name: "Chase", url: "https://chase.com", favorite: false, tags: [T.finance], created: "2024-02-18", updated: "2025-04-28", accounts: [ { username: "example@entropysoftworks.com", password: "Sf6!nQ9xK3mD2p", notes: "Checking + card.", }, ], }, { id: "6", name: "Spotify", url: "https://spotify.com", favorite: false, tags: [T.streaming], created: "2024-05-30", updated: "2025-03-19", accounts: [{ username: "example@entropysoftworks.com", password: "Vb7nQ9xK3mD2p" }], }, { id: "7", name: "Steam", url: "https://steampowered.com", favorite: false, tags: [T.gaming], created: "2024-06-01", updated: "2025-02-09", accounts: [{ username: "yourhandle", password: "Lr4!tWp2qZ", totp_secret: "NB2W45DFOIZA" }], }, { id: "8", name: "Reddit", url: "https://reddit.com", favorite: false, tags: [T.social], created: "2024-01-22", updated: "2025-01-15", accounts: [{ username: "u/yourhandle", password: "Dx2!mNp9qR" }], }, ]; export const SORT_OPTIONS = [ { value: "name_asc", label: "Name (A-Z)", cmp: (a: Item, b: Item) => a.name.localeCompare(b.name), }, { value: "name_desc", label: "Name (Z-A)", cmp: (a: Item, b: Item) => b.name.localeCompare(a.name), }, { value: "updated_newest", label: "Recently updated", cmp: (a: Item, b: Item) => b.updated.localeCompare(a.updated), }, { value: "created_newest", label: "Newest first", cmp: (a: Item, b: Item) => b.created.localeCompare(a.created), }, { value: "created_oldest", label: "Oldest first", cmp: (a: Item, b: Item) => a.created.localeCompare(b.created), }, ] as const; /** The sort used when an index somehow falls outside SORT_OPTIONS. */ const DEFAULT_SORT = SORT_OPTIONS[0]; // ── helpers ─────────────────────────────────────────────────────────────── export function host(url: string) { return url.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); } // Real ItemIcon renders the site favicon; reproduce with the same favicon // service the app's icon resolver falls back to. export function favicon(url: string) { return `https://www.google.com/s2/favicons?domain=${host(url)}&sz=64`; } export function ItemIcon({ url, size = 40, radius = 8, }: { url: string; size?: number; radius?: number; }) { return ( {/* RN `Image`, not a DOM ``: this component renders on native too, where `` is not a thing. `resizeMode="contain"` is the RN spelling of `object-fit: contain`. */} ); } export function useCopy() { const [copied, setCopied] = useState(null); const copy = useCallback((key: string, text: string) => { if (typeof navigator !== "undefined" && navigator.clipboard) { navigator.clipboard.writeText(text).catch(() => {}); } setCopied(key); setTimeout(() => setCopied((k) => (k === key ? null : k)), 1300); }, []); return { copied, copy }; } // Deterministic 6-digit code that rolls every 30s (stand-in for real TOTP). export function totpCode(seed: string, step: number) { let h = 0; for (const c of seed) { h = (h * 31 + c.charCodeAt(0)) >>> 0; } return Math.abs((h ^ (step * 2654435761)) % 1_000_000) .toString() .padStart(6, "0"); } export function useTotp(secret?: string) { const [now, setNow] = useState(0); useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); setNow(Date.now()); return () => clearInterval(t); }, []); if (!secret || !now) { return { code: "------", remaining: 30 }; } const epoch = Math.floor(now / 1000); return { code: totpCode(secret, Math.floor(epoch / 30)), remaining: 30 - (epoch % 30) }; } // Matches the app's RingCountdown widget. export function RingCountdown({ remaining, size = 48, period = 30, color, }: { remaining: number; size?: number; period?: number; color: string; }) { const r = size / 2 - 3; const c = 2 * Math.PI * r; const frac = remaining / period; const warn = remaining <= 5; const stroke = warn ? "#ef4444" : color; return ( {remaining} ); } // ── phone frame ───────────────────────────────────────────────────────────── export function VaultItemRow({ item, ink, onPress, highlight, }: { item: Item; ink: ReturnType; onPress: () => void; highlight?: boolean; }) { // Sample data always has one; the guard is for the type checker, and // rendering nothing beats crashing if that ever stops being true. const acc = item.accounts[0]; return ( {item.name} {acc?.username || host(item.url)} {item.favorite && } void Linking.openURL(item.url).catch(() => undefined)} > ); } export function Dropdown({ open, onToggle, label, icon, children, ink, }: { open: boolean; onToggle: () => void; label: string; icon?: React.ReactNode; children: React.ReactNode; ink: ReturnType; }) { return ( {icon} {label} {open ? ( ) : ( )} {open && ( {children} )} ); } export function VaultList({ ink, onOpen, items = ITEMS, scrollRef, highlightId, }: { ink: ReturnType; onOpen: (id: string) => void; items?: Item[]; scrollRef?: React.Ref; highlightId?: string | null; }) { const [query, setQuery] = useState(""); const [sortIdx, setSortIdx] = useState(0); const [showSort, setShowSort] = useState(false); // `?? SORT_OPTIONS[0]!` rather than a non-null assertion on the index: // sortIdx is driven by the picker below and cannot leave range, but under // noUncheckedIndexedAccess the compiler cannot know that, and falling back to // the default sort is the right behaviour if it ever did. // Falls back to the first option rather than asserting non-null: sortIdx is // driven by the picker and cannot leave range, but the default sort is the // right behaviour if it ever did. const sort = SORT_OPTIONS[sortIdx] ?? DEFAULT_SORT; const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return items.filter( (i) => !q || i.name.toLowerCase().includes(q) || i.accounts.some((a) => a.username.toLowerCase().includes(q)) || host(i.url).includes(q) ); }, [query, items]); const favorites = filtered.filter((i) => i.favorite); const all = [...filtered.filter((i) => !i.favorite)].sort(sort.cmp); return ( {favorites.length > 0 && ( Favorites ({favorites.length}) {favorites.map((i) => ( onOpen(i.id)} highlight={highlightId === i.id} /> ))} )} {all.length > 0 && ( All Items ({all.length}) setShowSort((v) => !v)} label={sort.label} icon={} > {SORT_OPTIONS.map((o, idx) => ( { setSortIdx(idx); setShowSort(false); }} className={`px-3 py-1.5 active:opacity-70 ${idx === sortIdx ? "bg-secondary" : ""}`} > {o.label} ))} {all.map((i) => ( onOpen(i.id)} highlight={highlightId === i.id} /> ))} )} ); } // ── item detail (app/(tabs)/vault/[id]/index.tsx) ──────────────────────────── export function FieldRow({ label, value, ink, copied, copy, secret, totp, }: { label: string; value: string; ink: ReturnType; copied: string | null; copy: (k: string, t: string) => void; secret?: boolean; totp?: ReturnType; }) { const [show, setShow] = useState(false); const isCopied = copied === label; const display = totp ? totp.code : secret && !show ? "••••••••••••" : value; return ( {label} {display} {secret && ( setShow((v) => !v)} className="active:opacity-60"> {show ? : } )} {totp && ( )} copy(label, totp ? totp.code : value)} className="active:opacity-60" > {isCopied ? : } ); } export function ItemDetail({ item, ink, onBack, onDelete, forceCopiedLabel, scrollRef, }: { item: Item; ink: ReturnType; onBack: () => void; onDelete?: () => void; forceCopiedLabel?: string | null; scrollRef?: React.Ref; }) { // Sample data always has one; the guard is for the type checker, and // rendering nothing beats crashing if that ever stops being true. const acc = item.accounts[0]; const accSecret = acc?.totp_secret; const { copied, copy } = useCopy(); const totp = useTotp(accSecret); const [editing, setEditing] = useState(false); const [confirm, setConfirm] = useState(false); // In autoplay the copied highlight is driven externally; otherwise the // real per-field copy state. const effCopied = forceCopiedLabel ?? copied; if (editing) { return setEditing(false)} />; } // After the hooks, so the hook order never changes. Sample items always carry // an account; this is the type checker's guard, and rendering nothing beats // crashing if that ever stops being true. if (!acc) { return null; } function launch() { void Linking.openURL(item.url).catch(() => undefined); } return ( {/* top bar */} setEditing(true)} hitSlop={8} className="active:opacity-60"> setConfirm(true)} hitSlop={8} className="active:opacity-60"> {confirm && ( Delete "{item.name}"? This can't be undone. )} {/* header */} {item.name} {item.favorite && } {host(item.url)} {/* fields */} {acc.totp_secret && ( )} {/* notes */} Notes {acc.notes ? ( {acc.notes} ) : ( No notes — tap Edit to add some. )} {/* details */} Details Created: {item.created} Updated: {item.updated} Tags {item.tags.map((t) => { const Icon = t.icon === "circle" ? CircleIcon : t.icon === "star" ? Star : TagIcon; return ( {t.name} ); })} ); } // ── generator (entropy-ui PasswordGenerator, reproduced) ────────────────────── function ExtBottomNav({ active, onChange, ink, }: { active: string; onChange: (t: ExtTab) => void; ink: ReturnType; }) { const tabs: [ExtTab, string, typeof Lock][] = [ ["vault", "Login Items", Lock], ["tags", "Tags", Folder], ["send", "Send", Send], ["secrets", "Secrets", KeyRound], ]; return ( {tabs.map(([id, label, Icon]) => { const on = id === active; return ( onChange(id)} className="flex-1 items-center justify-center gap-0.5 py-2 active:opacity-70" > {label} ); })} ); } function ExtComingSoon({ icon, desc, ink, }: { icon: React.ReactNode; desc: string; ink: ReturnType; }) { return ( {icon} {desc} Coming soon ); } /** * The mark in the popup header. * * Supplied by the caller rather than loaded from a path. The original pointed * at `/products/vault_light.png`, which only resolves on the marketing site — * a shared package cannot reach into one consumer's public directory. Falls * back to the wordmark so the header is never empty. */ export function VaultMark({ logo }: { logo?: React.ReactNode }) { if (logo) { return <>{logo}; } return Vault; } function ExtHeader({ tab, ink, onAdd, onAddFolder, onSettings, logo, }: { tab: ExtTab; ink: ReturnType; onAdd: () => void; onAddFolder: () => void; onSettings: () => void; logo?: React.ReactNode; }) { return ( {tab === "tags" && ( )} ); } // ── Tags & folders (clients/extension TagsAndFoldersScreen) ────────────────── interface DTag { id: string; name: string; color: string; icon?: "tag" | "circle" | "star"; folderId: string | null; } interface DFolder { id: string; name: string; } const SEED_FOLDERS: DFolder[] = [ { id: "f1", name: "Personal" }, { id: "f2", name: "Finance" }, { id: "f3", name: "Gaming" }, ]; const SEED_TAGS: DTag[] = [ { id: "t1", ...T.social, folderId: "f1" }, { id: "t2", ...T.streaming, folderId: "f1" }, { id: "t3", ...T.finance, folderId: "f2" }, { id: "t4", ...T.gaming, folderId: "f3" }, { id: "t5", ...T.shopping, folderId: null }, ]; function TagChip({ tag }: { tag: DTag }) { const Icon = tag.icon === "circle" ? CircleIcon : tag.icon === "star" ? Star : TagIcon; return ( {tag.name} ); } function ExtDragHandle({ ink }: { ink: ReturnType }) { // Affordance only. The website's version wires this to @dnd-kit, which is // DOM-only and cannot ship in a component React Native also renders. The grip // stays because removing it would change the extension's actual layout, which // is the thing this demo exists to reproduce. return ( ); } function ExtTagRow({ tag, ink, onEdit, onDelete, }: { tag: DTag; ink: ReturnType; onEdit: () => void; onDelete: () => void; }) { return ( ); } function ExtFolderBlock({ folder, tags, collapsed, ink, onToggle, onAdd, onEdit, onDelete, onEditTag, onDeleteTag, }: { folder: DFolder; tags: DTag[]; collapsed: boolean; ink: ReturnType; onToggle: () => void; onAdd: () => void; onEdit: () => void; onDelete: () => void; onEditTag: (t: DTag) => void; onDeleteTag: (id: string) => void; }) { return ( {collapsed ? ( ) : ( )} {folder.name} {tags.length} {!collapsed && ( {tags.length === 0 ? ( Drop tags here, or use + to add ) : ( tags.map((t) => ( onEditTag(t)} onDelete={() => onDeleteTag(t.id)} /> )) )} )} ); } function ExtUngrouped({ tags, ink, onEditTag, onDeleteTag, }: { tags: DTag[]; ink: ReturnType; onEditTag: (t: DTag) => void; onDeleteTag: (id: string) => void; }) { return ( Ungrouped {tags.length === 0 ? ( No tags ) : ( tags.map((t) => ( onEditTag(t)} onDelete={() => onDeleteTag(t.id)} /> )) )} ); } function ExtTagsTab({ ink }: { ink: ReturnType }) { const [folders, setFolders] = useState(SEED_FOLDERS); const [tags, setTags] = useState(SEED_TAGS); const [collapsed, setCollapsed] = useState>(new Set()); const [modal, setModal] = useState< { kind: "tag"; tag?: DTag } | { kind: "folder"; folder?: DFolder } | null >(null); function toggle(id: string) { setCollapsed((s) => { const n = new Set(s); if (n.has(id)) { n.delete(id); } else { n.add(id); } return n; }); } function deleteTag(id: string) { setTags((s) => s.filter((t) => t.id !== id)); } function deleteFolder(id: string) { setFolders((s) => s.filter((f) => f.id !== id)); setTags((s) => s.map((t) => (t.folderId === id ? { ...t, folderId: null } : t))); } const ungrouped = tags.filter((t) => !t.folderId); return ( {folders.map((f) => ( t.folderId === f.id)} collapsed={collapsed.has(f.id)} ink={ink} onToggle={() => toggle(f.id)} onAdd={() => setModal({ kind: "tag" })} onEdit={() => setModal({ kind: "folder", folder: f })} onDelete={() => deleteFolder(f.id)} onEditTag={(t) => setModal({ kind: "tag", tag: t })} onDeleteTag={deleteTag} /> ))} setModal({ kind: "tag", tag: t })} onDeleteTag={deleteTag} /> {modal?.kind === "tag" && ( setModal(null)} /> )} {modal?.kind === "folder" && ( setModal(null)} /> )} ); } function ExtSettingsRow({ icon, label, ink, onPress, }: { icon: React.ReactNode; label: string; ink: ReturnType; onPress?: () => void; }) { return ( {icon} {label} ); } // Settings flow (clients/extension SettingsScreen + PasskeyListScreen + // ServerConfigScreen). Owns its own little sub-router so the gear is a full // port: menu → Passkeys → Add passkey, and menu → Server URL. function ExtSettings({ ink, onBack }: { ink: ReturnType; onBack: () => void }) { const [sub, setSub] = useState<"menu" | "passkeys" | "enroll" | "server">("menu"); if (sub === "passkeys") { return setSub("menu")} onAdd={() => setSub("enroll")} />; } if (sub === "enroll") { return setSub("passkeys")} />; } if (sub === "server") { return setSub("menu")} />; } return ( Done Settings } label="Passkeys" onPress={() => setSub("passkeys")} /> } label="Server URL" onPress={() => setSub("server")} /> Signing out clears your session token. Your encrypted vault stays on the server. ); } // Passkey management (PasskeyListScreen) — popup directs rename/delete to the // main app behind the MFA gate; "Add passkey" stays one click away. function ExtPasskeys({ ink, onBack, onAdd, }: { ink: ReturnType; onBack: () => void; onAdd: () => void; }) { return ( Passkeys Register a new passkey for this account using your browser's built-in WebAuthn dialog. Rename or remove passkeys in the main app Renaming and deleting passkeys requires multi-factor verification. The extension popup doesn't surface a TOTP prompt today, so those actions live in the main Vault app (web or mobile). vault.example.com ); } function ExtEnroll({ ink, onBack }: { ink: ReturnType; onBack: () => void }) { return ( ); } // Server URL editor (ServerConfigScreen). Test runs a (mocked) reachability // check and shows the same success banner the real screen does. // Real reachability probe — ports api/health.ts. Parses the URL, GETs // `/health`, and only reports success on a JSON `{status:"healthy"}`. // A bad URL or unreachable host genuinely fails (no mock), so a typo can't // "pass". Cross-origin servers without CORS show as unreachable — honest for // a probe fired from the browser. type HealthResult = { ok: boolean; message: string } | null; async function probeServer(rawUrl: string): Promise { const trimmed = rawUrl.trim(); let parsed: URL; try { parsed = new URL(trimmed); } catch { return { ok: false, message: "Invalid URL format" }; } const target = `${trimmed.replace(/\/+$/, "")}/health`; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 8000); try { const res = await fetch(target, { method: "GET", credentials: "omit", signal: ctrl.signal }); if (!res.ok) { return { ok: false, message: `Server responded with HTTP ${res.status}` }; } let body: { status?: string; version?: string } = {}; try { body = (await res.json()) as { status?: string; version?: string }; } catch { return { ok: false, message: "Unexpected response body" }; } if (body.status !== "healthy") { return { ok: false, message: `Server reported status: ${body.status ?? "unknown"}` }; } return { ok: true, message: body.version ? `Server reachable (version ${body.version})` : "Server reachable", }; } catch (e) { const reason = e instanceof Error ? e.message : "network error"; if (parsed.protocol === "https:" && reason.toLowerCase().includes("certificate")) { return { ok: false, message: "TLS certificate validation failed" }; } if (reason.toLowerCase().includes("abort") || reason.toLowerCase().includes("timeout")) { return { ok: false, message: "Request timed out" }; } return { ok: false, message: `Could not reach server (${reason})` }; } finally { clearTimeout(timer); } } function ExtServerConfig({ ink, onBack }: { ink: ReturnType; onBack: () => void }) { const DEFAULT_URL = "https://vault.example.com/api"; const [url, setUrl] = useState(DEFAULT_URL); const [testing, setTesting] = useState(false); const [result, setResult] = useState(null); async function test() { setTesting(true); setResult(null); const r = await probeServer(url); setResult(r); setTesting(false); } return ( Server URL Point this extension at your self-hosted Vault server. HTTPS required (loopback may use HTTP). { setUrl(v); setResult(null); }} ink={ink} /> {result && ( {result.ok ? "✓ " : "✗ "} {result.message} )} Back ); } function ExtField({ label, placeholder, value, onChange, ink, secret, }: { label: string; placeholder: string; value: string; onChange: (v: string) => void; ink: ReturnType; secret?: boolean; }) { const [show, setShow] = useState(false); return ( {label} {secret && ( setShow((s) => !s)} className="active:opacity-60"> {show ? : } )} ); } // New / edit login form (clients/extension VaultItemFormScreen). With `item` // the fields prefill and the screen acts as the editor. function ExtItemForm({ ink, onCancel, item, }: { ink: ReturnType; onCancel: () => void; item?: Item; }) { const acc = item?.accounts[0]; const [fav, setFav] = useState(item?.favorite ?? false); const [picked, setPicked] = useState(item?.tags.map((t) => t.name) ?? []); const [f, setF] = useState({ name: item?.name ?? "", url: item?.url ?? "", label: "", username: acc?.username ?? "", password: acc?.password ?? "", totp: acc?.totp_secret ?? "", notes: acc?.notes ?? "", }); const set = (k: keyof typeof f) => (v: string) => setF((s) => ({ ...s, [k]: v })); const allTags = Object.values(T); return ( {item ? "Edit item" : "New item"} setFav((v) => !v)} className="active:opacity-70"> Tags {allTags.map((t) => { const on = picked.includes(t.name); const Icon = t.icon === "circle" ? CircleIcon : t.icon === "star" ? Star : TagIcon; return ( setPicked((s) => (on ? s.filter((x) => x !== t.name) : [...s, t.name])) } className="flex-row items-center gap-1 rounded-md border px-2 py-1" style={ { borderColor: `${t.color}${on ? "" : "55"}`, backgroundColor: on ? `${t.color}22` : "transparent", } as never } > {t.name} ); })} {}} className="flex-row items-center justify-center gap-2 rounded-lg border border-border py-2.5 active:opacity-80" > Add account ); } function ExtModal({ title, ink, children, }: { title: string; ink: ReturnType; children: React.ReactNode; }) { void ink; return ( {title} {children} ); } const TAG_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#a855f7", "#06b6d4", "#ef4444"]; function ExtTagModal({ ink, onClose, tag, }: { ink: ReturnType; onClose: () => void; tag?: DTag; }) { const [name, setName] = useState(tag?.name ?? ""); const [color, setColor] = useState(tag?.color ?? TAG_COLORS[0]); return ( Color {TAG_COLORS.map((c) => ( setColor(c)} style={ { width: 28, height: 28, borderRadius: 999, backgroundColor: c, borderWidth: color === c ? 2 : 0, borderColor: ink.fg, } as never } /> ))} ); } function ExtFolderModal({ ink, onClose, folder, }: { ink: ReturnType; onClose: () => void; folder?: DFolder; }) { const [name, setName] = useState(folder?.name ?? ""); return ( ); } type ExtTab = "vault" | "tags" | "send" | "secrets"; export type ExtRoute = | { r: "main" } | { r: "detail"; id: string } | { r: "settings" } | { r: "new" }; // External controller — lets a parent scene drive the popup (route, the // highlighted row, the "copied" field, and the list/detail scroll positions) // instead of the popup's own internal autoplay. Used by TotpScene so a single // scene-level cursor can hand the code off to a browser window. function PasskeyEnroll({ ink }: { ink: ReturnType }) { const [label, setLabel] = useState("My iPhone"); return ( Add a passkey Passkeys are a faster, phishing-resistant way to sign in. Use Face ID, Touch ID, or a hardware key. Your passkey stays on this device (or in iCloud Keychain / Google Password Manager). Vault never sees the private key. Passkey name Used to identify this passkey in your settings list. ); } // ── embedded single demo (registry) ─────────────────────────────────────────── /** * A parent-driven view of the popup. * * Lets a scene puppet the popup — scroll the list, highlight a row, open it, * show a field as copied — while the popup keeps owning how any of that looks. * All of it is RN-safe, which is why it survived the move. * * What did NOT survive is the original's self-driving `auto` mode: it steered a * synthetic cursor with `querySelector` and `getBoundingClientRect`, which have * no meaning on native. A scene that wants that keeps its own cursor and drives * this controller instead. */ export interface ExtensionPopupController { route: ExtRoute; highlightId: string | null; autoCopied: string | null; listScrollRef: React.Ref; detailScrollRef: React.Ref; } /** * The browser-extension popup. * * Interactive by default. Pass `external` to have a parent scene drive it. */ export function ExtensionPopup({ logo, external, height = EXTENSION_POPUP_HEIGHT, }: { logo?: React.ReactNode; external?: ExtensionPopupController; /** Frame height. Defaults to the full 600; see `EXTENSION_POPUP_HEIGHT`. */ height?: number; } = {}) { const ink = useInk(); const dark = ink.fg === "#ffffff"; const [tab, setTab] = useState("vault"); const [route, setRoute] = useState({ r: "main" }); const [modal, setModal] = useState<"tag" | "folder" | null>(null); const [items, setItems] = useState(ITEMS); const driven = !!external; const vroute = external ? external.route : route; const openItem = vroute.r === "detail" ? (items.find((i) => i.id === vroute.id) ?? null) : null; return ( {vroute.r === "detail" && openItem ? ( setRoute({ r: "main" })} onDelete={() => { const id = openItem.id; setItems((s) => s.filter((i) => i.id !== id)); setRoute({ r: "main" }); }} forceCopiedLabel={driven ? external?.autoCopied : undefined} scrollRef={external?.detailScrollRef} /> ) : vroute.r === "settings" ? ( setRoute({ r: "main" })} /> ) : vroute.r === "new" ? ( setRoute({ r: "main" })} /> ) : ( {/* Vault + Tags tabs show the logo header (matches the app); Send and Secrets are full-screen "coming soon" with no header. */} {(tab === "vault" || tab === "tags") && ( (tab === "tags" ? setModal("tag") : setRoute({ r: "new" }))} onAddFolder={() => setModal("folder")} onSettings={() => setRoute({ r: "settings" })} /> )} {tab === "vault" && ( setRoute({ r: "detail", id })} scrollRef={external?.listScrollRef} highlightId={external?.highlightId} /> )} {tab === "tags" && } {tab === "send" && ( } desc="Send files securely via link or email with permissions, auto-expiry, and encryption." /> )} {tab === "secrets" && ( } desc="Manage API keys, tokens, environment variables, and other developer secrets." /> )} { setTab(t); setRoute({ r: "main" }); }} ink={ink} /> {modal === "tag" && setModal(null)} />} {modal === "folder" && setModal(null)} />} )} ); } export default ExtensionPopup;