@entropy-softworks/ui 2026.8.34 → 2026.8.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/lib/components/auth/PasswordStrengthMeter.d.ts +9 -0
  3. package/lib/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
  4. package/lib/components/auth/PasswordStrengthMeter.jsx +10 -4
  5. package/lib/components/auth/PasswordStrengthMeter.jsx.map +1 -1
  6. package/lib/components/profile/SecurityTab.d.ts +5 -1
  7. package/lib/components/profile/SecurityTab.d.ts.map +1 -1
  8. package/lib/components/profile/SecurityTab.jsx.map +1 -1
  9. package/lib/components/segmented-toggle.d.ts +9 -0
  10. package/lib/components/segmented-toggle.d.ts.map +1 -1
  11. package/lib/components/segmented-toggle.jsx +4 -0
  12. package/lib/components/segmented-toggle.jsx.map +1 -1
  13. package/lib/components/social-connections.d.ts +22 -5
  14. package/lib/components/social-connections.d.ts.map +1 -1
  15. package/lib/components/social-connections.jsx +37 -11
  16. package/lib/components/social-connections.jsx.map +1 -1
  17. package/lib/context/AppConfigContext.d.ts +27 -0
  18. package/lib/context/AppConfigContext.d.ts.map +1 -1
  19. package/lib/context/AppConfigContext.jsx.map +1 -1
  20. package/lib/demos/extension-popup.d.ts +212 -0
  21. package/lib/demos/extension-popup.d.ts.map +1 -0
  22. package/lib/demos/extension-popup.jsx +1172 -0
  23. package/lib/demos/extension-popup.jsx.map +1 -0
  24. package/lib/demos/index.d.ts +11 -0
  25. package/lib/demos/index.d.ts.map +1 -0
  26. package/lib/demos/index.js +14 -0
  27. package/lib/demos/index.js.map +1 -0
  28. package/lib/screens/auth/AuthScreen.d.ts +75 -1
  29. package/lib/screens/auth/AuthScreen.d.ts.map +1 -1
  30. package/lib/screens/auth/AuthScreen.jsx +356 -122
  31. package/lib/screens/auth/AuthScreen.jsx.map +1 -1
  32. package/lib/services/auth.service.d.ts +41 -1
  33. package/lib/services/auth.service.d.ts.map +1 -1
  34. package/lib/services/auth.service.js +53 -7
  35. package/lib/services/auth.service.js.map +1 -1
  36. package/package.json +7 -1
  37. package/src/components/auth/PasswordStrengthMeter.tsx +25 -3
  38. package/src/components/profile/SecurityTab.tsx +11 -1
  39. package/src/components/segmented-toggle.tsx +13 -0
  40. package/src/components/social-connections.tsx +66 -20
  41. package/src/context/AppConfigContext.tsx +27 -0
  42. package/src/demos/extension-popup.tsx +2051 -0
  43. package/src/demos/index.ts +38 -0
  44. package/src/screens/auth/AuthScreen.tsx +701 -312
  45. package/src/services/auth.service.ts +62 -8
@@ -0,0 +1,2051 @@
1
+ /**
2
+ * The Vault browser-extension popup, as a shared component.
3
+ *
4
+ * A faithful recreation of `clients/extension` — the real popup's routes,
5
+ * layout, copy, field labels, sort options and tag/folder structure — running on
6
+ * sample data that never leaves the device. Interactive: open an item, copy a
7
+ * field, reveal a TOTP, switch tabs, walk into settings.
8
+ *
9
+ * It lives HERE rather than in either consumer because both the marketing site
10
+ * and the Vault app show it, and two copies of a 1,000-line recreation would
11
+ * drift the moment the real extension changed. Same reasoning as sharing
12
+ * `PasswordGenerator` instead of rewriting it per app.
13
+ *
14
+ * Two things the website's original did that this deliberately does not:
15
+ *
16
+ * * **No `auto` mode.** That drove a synthetic cursor with `querySelector` and
17
+ * `getBoundingClientRect`, which has no meaning on native. It belongs to that
18
+ * page's choreography, not to the extension, so the showcase keeps it.
19
+ * * **No drag-and-drop in the tags tab.** It used `@dnd-kit`, which is DOM-only.
20
+ * The grip handles remain as affordances because removing them would change
21
+ * the layout this exists to reproduce, but they do not drag.
22
+ *
23
+ * Everything else is the original, moved rather than rewritten.
24
+ */
25
+
26
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
27
+ import {
28
+ Image,
29
+ Linking,
30
+ Pressable,
31
+ ScrollView,
32
+ Text as RNText,
33
+ TextInput,
34
+ View,
35
+ } from "react-native";
36
+ import { useColorScheme, vars } from "nativewind";
37
+ import {
38
+ ArrowDownUp,
39
+ ArrowLeft,
40
+ Check,
41
+ ChevronDown,
42
+ ChevronLeft,
43
+ ChevronRight,
44
+ ChevronUp,
45
+ Circle as CircleIcon,
46
+ Copy,
47
+ ExternalLink,
48
+ Eye,
49
+ EyeOff,
50
+ Fingerprint,
51
+ Folder,
52
+ FolderPlus,
53
+ GripVertical,
54
+ KeyRound,
55
+ Lock,
56
+ LogOut,
57
+ Pencil,
58
+ Plus,
59
+ Search,
60
+ Send,
61
+ Server,
62
+ Settings as SettingsIcon,
63
+ ShieldAlert,
64
+ SquarePen,
65
+ Star,
66
+ Tag as TagIcon,
67
+ Trash2,
68
+ } from "lucide-react-native";
69
+ import { MutedText, Text } from "../components/text";
70
+ import { Button } from "../components/button";
71
+ import {
72
+ Card,
73
+ CardContent,
74
+ CardDescription,
75
+ CardFooter,
76
+ CardHeader,
77
+ CardTitle,
78
+ } from "../components/card";
79
+
80
+ /**
81
+ * The real popup's frame, in px.
82
+ *
83
+ * `HEIGHT` is Chrome's *maximum* for a browser action popup, not a fixed size —
84
+ * the real popup is as tall as it needs to be, up to this. So a host with less
85
+ * vertical room may render it shorter (see the `height` prop) without the thing
86
+ * on screen becoming a lie about what the extension looks like; the item list
87
+ * scrolls inside, exactly as it does at full height.
88
+ */
89
+ export const EXTENSION_POPUP_WIDTH = 380;
90
+ export const EXTENSION_POPUP_HEIGHT = 600;
91
+ /** Below this the chrome crowds out the list and it stops reading as the popup. */
92
+ export const EXTENSION_POPUP_MIN_HEIGHT = 320;
93
+
94
+ export function useInk() {
95
+ const { colorScheme: scheme } = useColorScheme();
96
+ const dark = scheme === "dark";
97
+ // Vault's own palette (pure white in light), matching .vault-surface.
98
+ return {
99
+ fg: dark ? "#ffffff" : "#000000",
100
+ muted: dark ? "#a3a3a3" : "#737373",
101
+ bg: dark ? "#000000" : "#ffffff",
102
+ border: dark ? "#262626" : "#e5e5e5",
103
+ secondary: dark ? "#262626" : "#f5f5f5",
104
+ danger: "#ef4444",
105
+ star: "#f59e0b",
106
+ };
107
+ }
108
+
109
+ // Demo graphics are react-native-web, which SSRs markup that then re-lays-out
110
+ // on hydration ("pops in") and would render with the pre-mount theme guess
111
+ // (a dark flash on a light page). Gate them to mount client-side, behind a
112
+ // theme-correct placeholder, so they appear once, already in the right theme.
113
+
114
+ export function vaultVars(dark: boolean) {
115
+ return vars(
116
+ dark
117
+ ? {
118
+ "--background-rgb": "0 0 0",
119
+ "--foreground-rgb": "255 255 255",
120
+ "--card": "#0a0a0a",
121
+ "--card-foreground": "#ffffff",
122
+ "--secondary": "#262626",
123
+ "--secondary-foreground": "#ffffff",
124
+ "--muted": "#262626",
125
+ "--muted-foreground": "#a3a3a3",
126
+ "--border": "#262626",
127
+ "--primary": "#ffffff",
128
+ "--primary-foreground": "#000000",
129
+ }
130
+ : {
131
+ "--background-rgb": "255 255 255",
132
+ "--foreground-rgb": "0 0 0",
133
+ "--card": "#ffffff",
134
+ "--card-foreground": "#000000",
135
+ "--secondary": "#f5f5f5",
136
+ "--secondary-foreground": "#000000",
137
+ "--muted": "#f5f5f5",
138
+ "--muted-foreground": "#737373",
139
+ "--border": "#e5e5e5",
140
+ "--primary": "#000000",
141
+ "--primary-foreground": "#ffffff",
142
+ }
143
+ );
144
+ }
145
+
146
+ // ── sample data (shape mirrors DecryptedVaultItem.plaintext) ─────────────────
147
+ export interface Account {
148
+ username: string;
149
+ password: string;
150
+ totp_secret?: string;
151
+ notes?: string;
152
+ }
153
+ // Tags mirror the app's user-created tags: a name, a color, and an icon
154
+ // (pricetag / circle / star). Rendered as a colored-border chip with the icon.
155
+ export interface Tag {
156
+ name: string;
157
+ color: string;
158
+ icon?: "tag" | "circle" | "star";
159
+ }
160
+ export interface Item {
161
+ id: string;
162
+ name: string;
163
+ url: string;
164
+ favorite: boolean;
165
+ tags: Tag[];
166
+ accounts: Account[];
167
+ created: string;
168
+ updated: string;
169
+ }
170
+
171
+ export const T = {
172
+ social: { name: "social", color: "#3b82f6", icon: "circle" } as Tag,
173
+ shopping: { name: "shopping", color: "#22c55e", icon: "tag" } as Tag,
174
+ finance: { name: "finance", color: "#f59e0b", icon: "star" } as Tag,
175
+ streaming: { name: "streaming", color: "#a855f7", icon: "circle" } as Tag,
176
+ gaming: { name: "gaming", color: "#06b6d4", icon: "tag" } as Tag,
177
+ };
178
+
179
+ export const ITEMS: Item[] = [
180
+ {
181
+ id: "1",
182
+ name: "Netflix",
183
+ url: "https://netflix.com",
184
+ favorite: true,
185
+ tags: [T.streaming],
186
+ created: "2024-02-11",
187
+ updated: "2025-05-30",
188
+ accounts: [
189
+ {
190
+ username: "example@entropysoftworks.com",
191
+ password: "tR0ub4dour&3xplsion",
192
+ totp_secret: "JBSWY3DPEHPK3PXP",
193
+ notes: "Shared family plan.",
194
+ },
195
+ ],
196
+ },
197
+ {
198
+ id: "2",
199
+ name: "Amazon",
200
+ url: "https://amazon.com",
201
+ favorite: true,
202
+ tags: [T.shopping],
203
+ created: "2024-01-04",
204
+ updated: "2025-05-22",
205
+ accounts: [
206
+ {
207
+ username: "example@entropysoftworks.com",
208
+ password: "correct-horse-battery",
209
+ totp_secret: "KRSXG5CTMVRXEZLU",
210
+ },
211
+ ],
212
+ },
213
+ {
214
+ id: "3",
215
+ name: "Instagram",
216
+ url: "https://instagram.com",
217
+ favorite: true,
218
+ tags: [T.social],
219
+ created: "2024-03-02",
220
+ updated: "2025-06-01",
221
+ accounts: [
222
+ { username: "yourhandle", password: "9xQvM2pL7wE4z", totp_secret: "MFRGGZDFMZTWQ2LK" },
223
+ ],
224
+ },
225
+ {
226
+ id: "4",
227
+ name: "PayPal",
228
+ url: "https://paypal.com",
229
+ favorite: false,
230
+ tags: [T.finance],
231
+ created: "2024-04-20",
232
+ updated: "2025-05-12",
233
+ accounts: [
234
+ {
235
+ username: "example@entropysoftworks.com",
236
+ password: "Zx8kLp2qWn5vR",
237
+ totp_secret: "GEZDGNBVGY3TQOJQ",
238
+ },
239
+ ],
240
+ },
241
+ {
242
+ id: "5",
243
+ name: "Chase",
244
+ url: "https://chase.com",
245
+ favorite: false,
246
+ tags: [T.finance],
247
+ created: "2024-02-18",
248
+ updated: "2025-04-28",
249
+ accounts: [
250
+ {
251
+ username: "example@entropysoftworks.com",
252
+ password: "Sf6!nQ9xK3mD2p",
253
+ notes: "Checking + card.",
254
+ },
255
+ ],
256
+ },
257
+ {
258
+ id: "6",
259
+ name: "Spotify",
260
+ url: "https://spotify.com",
261
+ favorite: false,
262
+ tags: [T.streaming],
263
+ created: "2024-05-30",
264
+ updated: "2025-03-19",
265
+ accounts: [{ username: "example@entropysoftworks.com", password: "Vb7nQ9xK3mD2p" }],
266
+ },
267
+ {
268
+ id: "7",
269
+ name: "Steam",
270
+ url: "https://steampowered.com",
271
+ favorite: false,
272
+ tags: [T.gaming],
273
+ created: "2024-06-01",
274
+ updated: "2025-02-09",
275
+ accounts: [{ username: "yourhandle", password: "Lr4!tWp2qZ", totp_secret: "NB2W45DFOIZA" }],
276
+ },
277
+ {
278
+ id: "8",
279
+ name: "Reddit",
280
+ url: "https://reddit.com",
281
+ favorite: false,
282
+ tags: [T.social],
283
+ created: "2024-01-22",
284
+ updated: "2025-01-15",
285
+ accounts: [{ username: "u/yourhandle", password: "Dx2!mNp9qR" }],
286
+ },
287
+ ];
288
+
289
+ export const SORT_OPTIONS = [
290
+ {
291
+ value: "name_asc",
292
+ label: "Name (A-Z)",
293
+ cmp: (a: Item, b: Item) => a.name.localeCompare(b.name),
294
+ },
295
+ {
296
+ value: "name_desc",
297
+ label: "Name (Z-A)",
298
+ cmp: (a: Item, b: Item) => b.name.localeCompare(a.name),
299
+ },
300
+ {
301
+ value: "updated_newest",
302
+ label: "Recently updated",
303
+ cmp: (a: Item, b: Item) => b.updated.localeCompare(a.updated),
304
+ },
305
+ {
306
+ value: "created_newest",
307
+ label: "Newest first",
308
+ cmp: (a: Item, b: Item) => b.created.localeCompare(a.created),
309
+ },
310
+ {
311
+ value: "created_oldest",
312
+ label: "Oldest first",
313
+ cmp: (a: Item, b: Item) => a.created.localeCompare(b.created),
314
+ },
315
+ ] as const;
316
+
317
+ /** The sort used when an index somehow falls outside SORT_OPTIONS. */
318
+ const DEFAULT_SORT = SORT_OPTIONS[0];
319
+
320
+ // ── helpers ───────────────────────────────────────────────────────────────
321
+ export function host(url: string) {
322
+ return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
323
+ }
324
+ // Real ItemIcon renders the site favicon; reproduce with the same favicon
325
+ // service the app's icon resolver falls back to.
326
+ export function favicon(url: string) {
327
+ return `https://www.google.com/s2/favicons?domain=${host(url)}&sz=64`;
328
+ }
329
+
330
+ export function ItemIcon({
331
+ url,
332
+ size = 40,
333
+ radius = 8,
334
+ }: {
335
+ url: string;
336
+ size?: number;
337
+ radius?: number;
338
+ }) {
339
+ return (
340
+ <View
341
+ style={
342
+ {
343
+ width: size,
344
+ height: size,
345
+ borderRadius: radius,
346
+ overflow: "hidden",
347
+ alignItems: "center",
348
+ justifyContent: "center",
349
+ backgroundColor: "#ffffff",
350
+ borderWidth: 1,
351
+ borderColor: "#e5e5e5",
352
+ } as never
353
+ }
354
+ >
355
+ {/* RN `Image`, not a DOM `<img>`: this component renders on native too,
356
+ where `<img>` is not a thing. `resizeMode="contain"` is the RN
357
+ spelling of `object-fit: contain`. */}
358
+ <Image
359
+ source={{ uri: favicon(url) }}
360
+ accessibilityIgnoresInvertColors
361
+ style={{ width: size * 0.6, height: size * 0.6 }}
362
+ resizeMode="contain"
363
+ />
364
+ </View>
365
+ );
366
+ }
367
+
368
+ export function useCopy() {
369
+ const [copied, setCopied] = useState<string | null>(null);
370
+ const copy = useCallback((key: string, text: string) => {
371
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
372
+ navigator.clipboard.writeText(text).catch(() => {});
373
+ }
374
+ setCopied(key);
375
+ setTimeout(() => setCopied((k) => (k === key ? null : k)), 1300);
376
+ }, []);
377
+ return { copied, copy };
378
+ }
379
+
380
+ // Deterministic 6-digit code that rolls every 30s (stand-in for real TOTP).
381
+ export function totpCode(seed: string, step: number) {
382
+ let h = 0;
383
+ for (const c of seed) {
384
+ h = (h * 31 + c.charCodeAt(0)) >>> 0;
385
+ }
386
+ return Math.abs((h ^ (step * 2654435761)) % 1_000_000)
387
+ .toString()
388
+ .padStart(6, "0");
389
+ }
390
+ export function useTotp(secret?: string) {
391
+ const [now, setNow] = useState(0);
392
+ useEffect(() => {
393
+ const t = setInterval(() => setNow(Date.now()), 1000);
394
+ setNow(Date.now());
395
+ return () => clearInterval(t);
396
+ }, []);
397
+ if (!secret || !now) {
398
+ return { code: "------", remaining: 30 };
399
+ }
400
+ const epoch = Math.floor(now / 1000);
401
+ return { code: totpCode(secret, Math.floor(epoch / 30)), remaining: 30 - (epoch % 30) };
402
+ }
403
+
404
+ // Matches the app's RingCountdown widget.
405
+ export function RingCountdown({
406
+ remaining,
407
+ size = 48,
408
+ period = 30,
409
+ color,
410
+ }: {
411
+ remaining: number;
412
+ size?: number;
413
+ period?: number;
414
+ color: string;
415
+ }) {
416
+ const r = size / 2 - 3;
417
+ const c = 2 * Math.PI * r;
418
+ const frac = remaining / period;
419
+ const warn = remaining <= 5;
420
+ const stroke = warn ? "#ef4444" : color;
421
+ return (
422
+ <View style={{ width: size, height: size } as never}>
423
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
424
+ <circle
425
+ cx={size / 2}
426
+ cy={size / 2}
427
+ r={r}
428
+ fill="none"
429
+ stroke={color}
430
+ strokeOpacity={0.2}
431
+ strokeWidth={3}
432
+ />
433
+ <circle
434
+ cx={size / 2}
435
+ cy={size / 2}
436
+ r={r}
437
+ fill="none"
438
+ stroke={stroke}
439
+ strokeWidth={3}
440
+ strokeLinecap="round"
441
+ strokeDasharray={c}
442
+ strokeDashoffset={c * (1 - frac)}
443
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
444
+ />
445
+ <text
446
+ x={size / 2}
447
+ y={size / 2 + 4}
448
+ textAnchor="middle"
449
+ fontSize={12}
450
+ fontFamily="var(--font-geist-mono)"
451
+ fill={stroke}
452
+ >
453
+ {remaining}
454
+ </text>
455
+ </svg>
456
+ </View>
457
+ );
458
+ }
459
+
460
+ // ── phone frame ─────────────────────────────────────────────────────────────
461
+
462
+ export function VaultItemRow({
463
+ item,
464
+ ink,
465
+ onPress,
466
+ highlight,
467
+ }: {
468
+ item: Item;
469
+ ink: ReturnType<typeof useInk>;
470
+ onPress: () => void;
471
+ highlight?: boolean;
472
+ }) {
473
+ // Sample data always has one; the guard is for the type checker, and
474
+ // rendering nothing beats crashing if that ever stops being true.
475
+ const acc = item.accounts[0];
476
+ return (
477
+ <Pressable
478
+ nativeID={`vrow-${item.id}`}
479
+ onPress={onPress}
480
+ className="mb-1.5 flex-row items-center overflow-hidden rounded-lg bg-card p-3.5 active:opacity-80"
481
+ style={highlight ? ({ backgroundColor: ink.secondary } as never) : undefined}
482
+ >
483
+ <View className="mr-3">
484
+ <ItemIcon url={item.url} />
485
+ </View>
486
+ <View className="flex-1">
487
+ <Text className="text-sm font-semibold" numberOfLines={1}>
488
+ {item.name}
489
+ </Text>
490
+ <MutedText className="text-xs" numberOfLines={1}>
491
+ {acc?.username || host(item.url)}
492
+ </MutedText>
493
+ </View>
494
+ {item.favorite && <Star size={14} color={ink.fg} fill={ink.fg} />}
495
+ <Pressable
496
+ className="ml-1 px-1.5 py-1 active:opacity-70"
497
+ onPress={() => void Linking.openURL(item.url).catch(() => undefined)}
498
+ >
499
+ <ExternalLink size={18} color={ink.muted} />
500
+ </Pressable>
501
+ <ChevronRight size={18} color={ink.muted} />
502
+ </Pressable>
503
+ );
504
+ }
505
+
506
+ export function Dropdown({
507
+ open,
508
+ onToggle,
509
+ label,
510
+ icon,
511
+ children,
512
+ ink,
513
+ }: {
514
+ open: boolean;
515
+ onToggle: () => void;
516
+ label: string;
517
+ icon?: React.ReactNode;
518
+ children: React.ReactNode;
519
+ ink: ReturnType<typeof useInk>;
520
+ }) {
521
+ return (
522
+ <View style={{ position: "relative" } as never}>
523
+ <Pressable
524
+ onPress={onToggle}
525
+ className="flex-row items-center gap-1 rounded-md border border-border px-2 py-1 active:opacity-70"
526
+ >
527
+ {icon}
528
+ <MutedText className="text-xs">{label}</MutedText>
529
+ {open ? (
530
+ <ChevronUp size={10} color={ink.muted} />
531
+ ) : (
532
+ <ChevronDown size={10} color={ink.muted} />
533
+ )}
534
+ </Pressable>
535
+ {open && (
536
+ <View
537
+ className="absolute right-0 top-8 z-50 rounded-lg border border-border bg-card py-1"
538
+ style={{ minWidth: 160 } as never}
539
+ >
540
+ {children}
541
+ </View>
542
+ )}
543
+ </View>
544
+ );
545
+ }
546
+
547
+ export function VaultList({
548
+ ink,
549
+ onOpen,
550
+ items = ITEMS,
551
+ scrollRef,
552
+ highlightId,
553
+ }: {
554
+ ink: ReturnType<typeof useInk>;
555
+ onOpen: (id: string) => void;
556
+ items?: Item[];
557
+ scrollRef?: React.Ref<ScrollView>;
558
+ highlightId?: string | null;
559
+ }) {
560
+ const [query, setQuery] = useState("");
561
+ const [sortIdx, setSortIdx] = useState(0);
562
+ const [showSort, setShowSort] = useState(false);
563
+ // `?? SORT_OPTIONS[0]!` rather than a non-null assertion on the index:
564
+ // sortIdx is driven by the picker below and cannot leave range, but under
565
+ // noUncheckedIndexedAccess the compiler cannot know that, and falling back to
566
+ // the default sort is the right behaviour if it ever did.
567
+ // Falls back to the first option rather than asserting non-null: sortIdx is
568
+ // driven by the picker and cannot leave range, but the default sort is the
569
+ // right behaviour if it ever did.
570
+ const sort = SORT_OPTIONS[sortIdx] ?? DEFAULT_SORT;
571
+
572
+ const filtered = useMemo(() => {
573
+ const q = query.trim().toLowerCase();
574
+ return items.filter(
575
+ (i) =>
576
+ !q ||
577
+ i.name.toLowerCase().includes(q) ||
578
+ i.accounts.some((a) => a.username.toLowerCase().includes(q)) ||
579
+ host(i.url).includes(q)
580
+ );
581
+ }, [query, items]);
582
+ const favorites = filtered.filter((i) => i.favorite);
583
+ const all = [...filtered.filter((i) => !i.favorite)].sort(sort.cmp);
584
+
585
+ return (
586
+ <View className="flex-1">
587
+ <View className="px-4 pb-2 pt-1">
588
+ <View
589
+ className="flex-row items-center rounded-lg border border-border px-3"
590
+ style={{ height: 40 } as never}
591
+ >
592
+ <Search size={18} color={ink.muted} />
593
+ <TextInput
594
+ value={query}
595
+ onChangeText={setQuery}
596
+ placeholder="Search passwords"
597
+ placeholderTextColor={ink.muted}
598
+ className="ml-2 flex-1 text-sm text-foreground"
599
+ style={{ outlineStyle: "none" } as never}
600
+ />
601
+ </View>
602
+ </View>
603
+
604
+ <ScrollView
605
+ ref={scrollRef}
606
+ className="flex-1"
607
+ contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 } as never}
608
+ >
609
+ {favorites.length > 0 && (
610
+ <View className="pt-2">
611
+ <View className="mb-2 flex-row items-center gap-1.5 px-1">
612
+ <Star size={14} color={ink.fg} />
613
+ <Text className="text-xs font-semibold uppercase tracking-wider">Favorites</Text>
614
+ <MutedText className="text-xs">({favorites.length})</MutedText>
615
+ </View>
616
+ {favorites.map((i) => (
617
+ <VaultItemRow
618
+ key={i.id}
619
+ item={i}
620
+ ink={ink}
621
+ onPress={() => onOpen(i.id)}
622
+ highlight={highlightId === i.id}
623
+ />
624
+ ))}
625
+ <View className="my-3 h-px bg-border" />
626
+ </View>
627
+ )}
628
+
629
+ {all.length > 0 && (
630
+ <View style={{ zIndex: 50 } as never}>
631
+ <View
632
+ className="relative mb-2 flex-row items-center justify-between px-1"
633
+ style={{ zIndex: 50 } as never}
634
+ >
635
+ <View className="flex-row items-center gap-1.5">
636
+ <Text className="text-xs font-semibold uppercase tracking-wider">All Items</Text>
637
+ <MutedText className="text-xs">({all.length})</MutedText>
638
+ </View>
639
+ <Dropdown
640
+ ink={ink}
641
+ open={showSort}
642
+ onToggle={() => setShowSort((v) => !v)}
643
+ label={sort.label}
644
+ icon={<ArrowDownUp size={12} color={ink.muted} />}
645
+ >
646
+ {SORT_OPTIONS.map((o, idx) => (
647
+ <Pressable
648
+ key={o.value}
649
+ onPress={() => {
650
+ setSortIdx(idx);
651
+ setShowSort(false);
652
+ }}
653
+ className={`px-3 py-1.5 active:opacity-70 ${idx === sortIdx ? "bg-secondary" : ""}`}
654
+ >
655
+ <Text className={`text-xs ${idx === sortIdx ? "font-semibold" : ""}`}>
656
+ {o.label}
657
+ </Text>
658
+ </Pressable>
659
+ ))}
660
+ </Dropdown>
661
+ </View>
662
+ {all.map((i) => (
663
+ <VaultItemRow
664
+ key={i.id}
665
+ item={i}
666
+ ink={ink}
667
+ onPress={() => onOpen(i.id)}
668
+ highlight={highlightId === i.id}
669
+ />
670
+ ))}
671
+ </View>
672
+ )}
673
+ </ScrollView>
674
+ </View>
675
+ );
676
+ }
677
+
678
+ // ── item detail (app/(tabs)/vault/[id]/index.tsx) ────────────────────────────
679
+ export function FieldRow({
680
+ label,
681
+ value,
682
+ ink,
683
+ copied,
684
+ copy,
685
+ secret,
686
+ totp,
687
+ }: {
688
+ label: string;
689
+ value: string;
690
+ ink: ReturnType<typeof useInk>;
691
+ copied: string | null;
692
+ copy: (k: string, t: string) => void;
693
+ secret?: boolean;
694
+ totp?: ReturnType<typeof useTotp>;
695
+ }) {
696
+ const [show, setShow] = useState(false);
697
+ const isCopied = copied === label;
698
+ const display = totp ? totp.code : secret && !show ? "••••••••••••" : value;
699
+ return (
700
+ <View className="mb-4">
701
+ <Text className="mb-1 text-xs text-muted-foreground">{label}</Text>
702
+ <View className="flex-row items-center justify-between rounded-lg bg-secondary p-3">
703
+ <RNText
704
+ className="flex-1 font-mono text-sm text-foreground"
705
+ numberOfLines={1}
706
+ style={{ color: ink.fg } as never}
707
+ >
708
+ {display}
709
+ </RNText>
710
+ <View className="flex-row items-center gap-2">
711
+ {secret && (
712
+ <Pressable onPress={() => setShow((v) => !v)} className="active:opacity-60">
713
+ {show ? <EyeOff size={18} color={ink.muted} /> : <Eye size={18} color={ink.muted} />}
714
+ </Pressable>
715
+ )}
716
+ {totp && (
717
+ <View className="mr-1">
718
+ <RingCountdown remaining={totp.remaining} color={ink.fg} />
719
+ </View>
720
+ )}
721
+ <Pressable
722
+ nativeID={totp ? "fcopy-totp" : undefined}
723
+ onPress={() => copy(label, totp ? totp.code : value)}
724
+ className="active:opacity-60"
725
+ >
726
+ {isCopied ? <Check size={18} color="#22c55e" /> : <Copy size={18} color={ink.muted} />}
727
+ </Pressable>
728
+ </View>
729
+ </View>
730
+ </View>
731
+ );
732
+ }
733
+
734
+ export function ItemDetail({
735
+ item,
736
+ ink,
737
+ onBack,
738
+ onDelete,
739
+ forceCopiedLabel,
740
+ scrollRef,
741
+ }: {
742
+ item: Item;
743
+ ink: ReturnType<typeof useInk>;
744
+ onBack: () => void;
745
+ onDelete?: () => void;
746
+ forceCopiedLabel?: string | null;
747
+ scrollRef?: React.Ref<ScrollView>;
748
+ }) {
749
+ // Sample data always has one; the guard is for the type checker, and
750
+ // rendering nothing beats crashing if that ever stops being true.
751
+ const acc = item.accounts[0];
752
+ const accSecret = acc?.totp_secret;
753
+ const { copied, copy } = useCopy();
754
+ const totp = useTotp(accSecret);
755
+ const [editing, setEditing] = useState(false);
756
+ const [confirm, setConfirm] = useState(false);
757
+ // In autoplay the copied highlight is driven externally; otherwise the
758
+ // real per-field copy state.
759
+ const effCopied = forceCopiedLabel ?? copied;
760
+
761
+ if (editing) {
762
+ return <ExtItemForm ink={ink} item={item} onCancel={() => setEditing(false)} />;
763
+ }
764
+ // After the hooks, so the hook order never changes. Sample items always carry
765
+ // an account; this is the type checker's guard, and rendering nothing beats
766
+ // crashing if that ever stops being true.
767
+ if (!acc) {
768
+ return null;
769
+ }
770
+
771
+ function launch() {
772
+ void Linking.openURL(item.url).catch(() => undefined);
773
+ }
774
+
775
+ return (
776
+ <ScrollView ref={scrollRef} className="flex-1" contentContainerStyle={{ padding: 16 } as never}>
777
+ {/* top bar */}
778
+ <View className="mb-4 flex-row items-center justify-between">
779
+ <Pressable onPress={onBack} className="active:opacity-70">
780
+ <ArrowLeft size={22} color={ink.fg} />
781
+ </Pressable>
782
+ <View className="flex-row gap-4">
783
+ <Pressable onPress={launch} hitSlop={8} className="active:opacity-60">
784
+ <ExternalLink size={20} color={ink.fg} />
785
+ </Pressable>
786
+ <Pressable onPress={() => setEditing(true)} hitSlop={8} className="active:opacity-60">
787
+ <SquarePen size={20} color={ink.fg} />
788
+ </Pressable>
789
+ <Pressable onPress={() => setConfirm(true)} hitSlop={8} className="active:opacity-60">
790
+ <Trash2 size={20} color={ink.danger} />
791
+ </Pressable>
792
+ </View>
793
+ </View>
794
+
795
+ {confirm && (
796
+ <ExtModal title="Delete item" ink={ink}>
797
+ <Text className="text-sm text-muted-foreground">
798
+ Delete &quot;{item.name}&quot;? This can&apos;t be undone.
799
+ </Text>
800
+ <View className="flex-row gap-2">
801
+ <View className="flex-1">
802
+ <Button variant="outline" onPress={() => setConfirm(false)}>
803
+ Cancel
804
+ </Button>
805
+ </View>
806
+ <View className="flex-1">
807
+ <Button
808
+ variant="destructive"
809
+ onPress={() => {
810
+ setConfirm(false);
811
+ (onDelete ?? onBack)();
812
+ }}
813
+ >
814
+ Delete
815
+ </Button>
816
+ </View>
817
+ </View>
818
+ </ExtModal>
819
+ )}
820
+
821
+ {/* header */}
822
+ <View className="mb-6 flex-row items-center gap-3">
823
+ <ItemIcon url={item.url} size={56} radius={14} />
824
+ <View className="flex-1">
825
+ <View className="flex-row items-center gap-2">
826
+ <Text className="text-xl font-bold">{item.name}</Text>
827
+ {item.favorite && <Star size={18} color={ink.star} fill={ink.star} />}
828
+ </View>
829
+ <MutedText className="text-sm" numberOfLines={1}>
830
+ {host(item.url)}
831
+ </MutedText>
832
+ </View>
833
+ </View>
834
+
835
+ {/* fields */}
836
+ <View className="rounded-lg border border-border bg-card p-4">
837
+ <FieldRow
838
+ label="Username / Email"
839
+ value={acc.username}
840
+ ink={ink}
841
+ copied={effCopied}
842
+ copy={copy}
843
+ />
844
+ <FieldRow
845
+ label="Password"
846
+ value={acc.password}
847
+ ink={ink}
848
+ copied={effCopied}
849
+ copy={copy}
850
+ secret
851
+ />
852
+ <FieldRow label="URL" value={item.url} ink={ink} copied={effCopied} copy={copy} />
853
+ {acc.totp_secret && (
854
+ <FieldRow
855
+ label="TOTP Code"
856
+ value={acc.totp_secret}
857
+ ink={ink}
858
+ copied={effCopied}
859
+ copy={copy}
860
+ totp={totp}
861
+ />
862
+ )}
863
+ </View>
864
+
865
+ {/* notes */}
866
+ <View className="mt-4 rounded-lg border border-border bg-card p-4">
867
+ <Text className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Notes</Text>
868
+ {acc.notes ? (
869
+ <Text className="text-sm">{acc.notes}</Text>
870
+ ) : (
871
+ <Text className="text-sm italic text-muted-foreground">
872
+ No notes — tap Edit to add some.
873
+ </Text>
874
+ )}
875
+ </View>
876
+
877
+ {/* details */}
878
+ <View className="mt-4 rounded-lg border border-border bg-card p-4">
879
+ <Text className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Details</Text>
880
+ <MutedText className="mb-1 text-xs">Created: {item.created}</MutedText>
881
+ <MutedText className="text-xs">Updated: {item.updated}</MutedText>
882
+ <View className="mt-3">
883
+ <MutedText className="mb-1.5 text-xs">Tags</MutedText>
884
+ <View className="flex-row flex-wrap gap-1.5">
885
+ {item.tags.map((t) => {
886
+ const Icon = t.icon === "circle" ? CircleIcon : t.icon === "star" ? Star : TagIcon;
887
+ return (
888
+ <View
889
+ key={t.name}
890
+ className="flex-row items-center gap-1 rounded-md border px-2 py-1"
891
+ style={{ borderColor: `${t.color}55` } as never}
892
+ >
893
+ <Icon size={12} color={t.color} />
894
+ <Text className="text-xs">{t.name}</Text>
895
+ </View>
896
+ );
897
+ })}
898
+ </View>
899
+ </View>
900
+ </View>
901
+ </ScrollView>
902
+ );
903
+ }
904
+
905
+ // ── generator (entropy-ui PasswordGenerator, reproduced) ──────────────────────
906
+
907
+ function ExtBottomNav({
908
+ active,
909
+ onChange,
910
+ ink,
911
+ }: {
912
+ active: string;
913
+ onChange: (t: ExtTab) => void;
914
+ ink: ReturnType<typeof useInk>;
915
+ }) {
916
+ const tabs: [ExtTab, string, typeof Lock][] = [
917
+ ["vault", "Login Items", Lock],
918
+ ["tags", "Tags", Folder],
919
+ ["send", "Send", Send],
920
+ ["secrets", "Secrets", KeyRound],
921
+ ];
922
+ return (
923
+ <View className="flex-row border-t border-border">
924
+ {tabs.map(([id, label, Icon]) => {
925
+ const on = id === active;
926
+ return (
927
+ <Pressable
928
+ key={id}
929
+ onPress={() => onChange(id)}
930
+ className="flex-1 items-center justify-center gap-0.5 py-2 active:opacity-70"
931
+ >
932
+ <Icon size={18} color={on ? ink.fg : ink.muted} />
933
+ <RNText
934
+ style={
935
+ {
936
+ fontSize: 10,
937
+ fontWeight: on ? "600" : "400",
938
+ color: on ? ink.fg : ink.muted,
939
+ } as never
940
+ }
941
+ >
942
+ {label}
943
+ </RNText>
944
+ </Pressable>
945
+ );
946
+ })}
947
+ </View>
948
+ );
949
+ }
950
+
951
+ function ExtComingSoon({
952
+ icon,
953
+ desc,
954
+ ink,
955
+ }: {
956
+ icon: React.ReactNode;
957
+ desc: string;
958
+ ink: ReturnType<typeof useInk>;
959
+ }) {
960
+ return (
961
+ <View className="flex-1 items-center justify-center px-8" style={{ gap: 10 } as never}>
962
+ {icon}
963
+ <MutedText className="text-center text-sm" style={{ maxWidth: 260 } as never}>
964
+ {desc}
965
+ </MutedText>
966
+ <RNText style={{ fontSize: 11, fontStyle: "italic", color: ink.muted } as never}>
967
+ Coming soon
968
+ </RNText>
969
+ </View>
970
+ );
971
+ }
972
+
973
+ /**
974
+ * The mark in the popup header.
975
+ *
976
+ * Supplied by the caller rather than loaded from a path. The original pointed
977
+ * at `/products/vault_light.png`, which only resolves on the marketing site —
978
+ * a shared package cannot reach into one consumer's public directory. Falls
979
+ * back to the wordmark so the header is never empty.
980
+ */
981
+ export function VaultMark({ logo }: { logo?: React.ReactNode }) {
982
+ if (logo) {
983
+ return <>{logo}</>;
984
+ }
985
+ return <Text className="text-base font-bold">Vault</Text>;
986
+ }
987
+
988
+ function ExtHeader({
989
+ tab,
990
+ ink,
991
+ onAdd,
992
+ onAddFolder,
993
+ onSettings,
994
+ logo,
995
+ }: {
996
+ tab: ExtTab;
997
+ ink: ReturnType<typeof useInk>;
998
+ onAdd: () => void;
999
+ onAddFolder: () => void;
1000
+ onSettings: () => void;
1001
+ logo?: React.ReactNode;
1002
+ }) {
1003
+ return (
1004
+ <View className="flex-row items-center gap-3 border-b border-border px-4 py-3">
1005
+ <VaultMark logo={logo} />
1006
+ <View className="flex-1" />
1007
+ {tab === "tags" && (
1008
+ <Pressable onPress={onAddFolder} className="active:opacity-70">
1009
+ <FolderPlus size={18} color={ink.fg} strokeWidth={1.75} />
1010
+ </Pressable>
1011
+ )}
1012
+ <Pressable onPress={onAdd} className="active:opacity-70">
1013
+ <Plus size={20} color={ink.fg} strokeWidth={1.75} />
1014
+ </Pressable>
1015
+ <Pressable onPress={onSettings} className="active:opacity-70">
1016
+ <SettingsIcon size={20} color={ink.fg} strokeWidth={1.75} />
1017
+ </Pressable>
1018
+ </View>
1019
+ );
1020
+ }
1021
+
1022
+ // ── Tags & folders (clients/extension TagsAndFoldersScreen) ──────────────────
1023
+
1024
+ interface DTag {
1025
+ id: string;
1026
+ name: string;
1027
+ color: string;
1028
+ icon?: "tag" | "circle" | "star";
1029
+ folderId: string | null;
1030
+ }
1031
+ interface DFolder {
1032
+ id: string;
1033
+ name: string;
1034
+ }
1035
+
1036
+ const SEED_FOLDERS: DFolder[] = [
1037
+ { id: "f1", name: "Personal" },
1038
+ { id: "f2", name: "Finance" },
1039
+ { id: "f3", name: "Gaming" },
1040
+ ];
1041
+ const SEED_TAGS: DTag[] = [
1042
+ { id: "t1", ...T.social, folderId: "f1" },
1043
+ { id: "t2", ...T.streaming, folderId: "f1" },
1044
+ { id: "t3", ...T.finance, folderId: "f2" },
1045
+ { id: "t4", ...T.gaming, folderId: "f3" },
1046
+ { id: "t5", ...T.shopping, folderId: null },
1047
+ ];
1048
+
1049
+ function TagChip({ tag }: { tag: DTag }) {
1050
+ const Icon = tag.icon === "circle" ? CircleIcon : tag.icon === "star" ? Star : TagIcon;
1051
+ return (
1052
+ <View
1053
+ className="flex-row items-center gap-1 self-start rounded-md border px-2 py-1"
1054
+ style={{ borderColor: `${tag.color}55` } as never}
1055
+ >
1056
+ <Icon size={11} color={tag.color} />
1057
+ <Text className="text-xs">{tag.name}</Text>
1058
+ </View>
1059
+ );
1060
+ }
1061
+
1062
+ function ExtDragHandle({ ink }: { ink: ReturnType<typeof useInk> }) {
1063
+ // Affordance only. The website's version wires this to @dnd-kit, which is
1064
+ // DOM-only and cannot ship in a component React Native also renders. The grip
1065
+ // stays because removing it would change the extension's actual layout, which
1066
+ // is the thing this demo exists to reproduce.
1067
+ return (
1068
+ <View>
1069
+ <GripVertical size={14} color={ink.muted} strokeWidth={1.75} />
1070
+ </View>
1071
+ );
1072
+ }
1073
+
1074
+ function ExtTagRow({
1075
+ tag,
1076
+ ink,
1077
+ onEdit,
1078
+ onDelete,
1079
+ }: {
1080
+ tag: DTag;
1081
+ ink: ReturnType<typeof useInk>;
1082
+ onEdit: () => void;
1083
+ onDelete: () => void;
1084
+ }) {
1085
+ return (
1086
+ <View className="flex-row items-center gap-2 rounded-md px-1.5 py-1.5">
1087
+ <ExtDragHandle ink={ink} />
1088
+ <View className="flex-1">
1089
+ <TagChip tag={tag} />
1090
+ </View>
1091
+ <Pressable onPress={onEdit} hitSlop={6} className="active:opacity-60">
1092
+ <Pencil size={13} color={ink.muted} strokeWidth={1.75} />
1093
+ </Pressable>
1094
+ <Pressable onPress={onDelete} hitSlop={6} className="active:opacity-60">
1095
+ <Trash2 size={13} color={ink.danger} strokeWidth={1.75} />
1096
+ </Pressable>
1097
+ </View>
1098
+ );
1099
+ }
1100
+
1101
+ function ExtFolderBlock({
1102
+ folder,
1103
+ tags,
1104
+ collapsed,
1105
+ ink,
1106
+ onToggle,
1107
+ onAdd,
1108
+ onEdit,
1109
+ onDelete,
1110
+ onEditTag,
1111
+ onDeleteTag,
1112
+ }: {
1113
+ folder: DFolder;
1114
+ tags: DTag[];
1115
+ collapsed: boolean;
1116
+ ink: ReturnType<typeof useInk>;
1117
+ onToggle: () => void;
1118
+ onAdd: () => void;
1119
+ onEdit: () => void;
1120
+ onDelete: () => void;
1121
+ onEditTag: (t: DTag) => void;
1122
+ onDeleteTag: (id: string) => void;
1123
+ }) {
1124
+ return (
1125
+ <View className="overflow-hidden rounded-lg border bg-card" style={{ borderColor: ink.border }}>
1126
+ <View className="flex-row items-center gap-2 px-2.5 py-2.5">
1127
+ <ExtDragHandle ink={ink} />
1128
+ <Pressable onPress={onToggle} hitSlop={4} className="active:opacity-60">
1129
+ {collapsed ? (
1130
+ <ChevronRight size={14} color={ink.muted} strokeWidth={1.75} />
1131
+ ) : (
1132
+ <ChevronDown size={14} color={ink.muted} strokeWidth={1.75} />
1133
+ )}
1134
+ </Pressable>
1135
+ <Folder size={14} color={ink.fg} strokeWidth={1.75} />
1136
+ <Pressable onPress={onToggle} className="flex-1">
1137
+ <Text className="text-[13px] font-semibold">{folder.name}</Text>
1138
+ </Pressable>
1139
+ <Text className="text-[11px] text-muted-foreground">{tags.length}</Text>
1140
+ <Pressable onPress={onAdd} hitSlop={6} className="active:opacity-60">
1141
+ <Plus size={14} color={ink.muted} strokeWidth={1.75} />
1142
+ </Pressable>
1143
+ <Pressable onPress={onEdit} hitSlop={6} className="active:opacity-60">
1144
+ <Pencil size={14} color={ink.muted} strokeWidth={1.75} />
1145
+ </Pressable>
1146
+ <Pressable onPress={onDelete} hitSlop={6} className="active:opacity-60">
1147
+ <Trash2 size={14} color={ink.danger} strokeWidth={1.75} />
1148
+ </Pressable>
1149
+ </View>
1150
+ {!collapsed && (
1151
+ <View className="px-2.5 pb-2.5" style={{ gap: 6 } as never}>
1152
+ {tags.length === 0 ? (
1153
+ <Text className="py-1 text-[11px] italic text-muted-foreground">
1154
+ Drop tags here, or use + to add
1155
+ </Text>
1156
+ ) : (
1157
+ tags.map((t) => (
1158
+ <ExtTagRow
1159
+ key={t.id}
1160
+ tag={t}
1161
+ ink={ink}
1162
+ onEdit={() => onEditTag(t)}
1163
+ onDelete={() => onDeleteTag(t.id)}
1164
+ />
1165
+ ))
1166
+ )}
1167
+ </View>
1168
+ )}
1169
+ </View>
1170
+ );
1171
+ }
1172
+
1173
+ function ExtUngrouped({
1174
+ tags,
1175
+ ink,
1176
+ onEditTag,
1177
+ onDeleteTag,
1178
+ }: {
1179
+ tags: DTag[];
1180
+ ink: ReturnType<typeof useInk>;
1181
+ onEditTag: (t: DTag) => void;
1182
+ onDeleteTag: (id: string) => void;
1183
+ }) {
1184
+ return (
1185
+ <View
1186
+ className="mt-1 rounded-lg border bg-card p-2.5"
1187
+ style={{ borderColor: ink.border, gap: 6 }}
1188
+ >
1189
+ <Text className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
1190
+ Ungrouped
1191
+ </Text>
1192
+ {tags.length === 0 ? (
1193
+ <Text className="py-1 text-[11px] italic text-muted-foreground">No tags</Text>
1194
+ ) : (
1195
+ tags.map((t) => (
1196
+ <ExtTagRow
1197
+ key={t.id}
1198
+ tag={t}
1199
+ ink={ink}
1200
+ onEdit={() => onEditTag(t)}
1201
+ onDelete={() => onDeleteTag(t.id)}
1202
+ />
1203
+ ))
1204
+ )}
1205
+ </View>
1206
+ );
1207
+ }
1208
+
1209
+ function ExtTagsTab({ ink }: { ink: ReturnType<typeof useInk> }) {
1210
+ const [folders, setFolders] = useState<DFolder[]>(SEED_FOLDERS);
1211
+ const [tags, setTags] = useState<DTag[]>(SEED_TAGS);
1212
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
1213
+ const [modal, setModal] = useState<
1214
+ { kind: "tag"; tag?: DTag } | { kind: "folder"; folder?: DFolder } | null
1215
+ >(null);
1216
+ function toggle(id: string) {
1217
+ setCollapsed((s) => {
1218
+ const n = new Set(s);
1219
+ if (n.has(id)) {
1220
+ n.delete(id);
1221
+ } else {
1222
+ n.add(id);
1223
+ }
1224
+ return n;
1225
+ });
1226
+ }
1227
+ function deleteTag(id: string) {
1228
+ setTags((s) => s.filter((t) => t.id !== id));
1229
+ }
1230
+ function deleteFolder(id: string) {
1231
+ setFolders((s) => s.filter((f) => f.id !== id));
1232
+ setTags((s) => s.map((t) => (t.folderId === id ? { ...t, folderId: null } : t)));
1233
+ }
1234
+
1235
+ const ungrouped = tags.filter((t) => !t.folderId);
1236
+ return (
1237
+ <View className="flex-1">
1238
+ <ScrollView className="flex-1" contentContainerStyle={{ padding: 12, gap: 8 } as never}>
1239
+ {folders.map((f) => (
1240
+ <ExtFolderBlock
1241
+ key={f.id}
1242
+ folder={f}
1243
+ tags={tags.filter((t) => t.folderId === f.id)}
1244
+ collapsed={collapsed.has(f.id)}
1245
+ ink={ink}
1246
+ onToggle={() => toggle(f.id)}
1247
+ onAdd={() => setModal({ kind: "tag" })}
1248
+ onEdit={() => setModal({ kind: "folder", folder: f })}
1249
+ onDelete={() => deleteFolder(f.id)}
1250
+ onEditTag={(t) => setModal({ kind: "tag", tag: t })}
1251
+ onDeleteTag={deleteTag}
1252
+ />
1253
+ ))}
1254
+ <ExtUngrouped
1255
+ tags={ungrouped}
1256
+ ink={ink}
1257
+ onEditTag={(t) => setModal({ kind: "tag", tag: t })}
1258
+ onDeleteTag={deleteTag}
1259
+ />
1260
+ </ScrollView>
1261
+ {modal?.kind === "tag" && (
1262
+ <ExtTagModal ink={ink} tag={modal.tag} onClose={() => setModal(null)} />
1263
+ )}
1264
+ {modal?.kind === "folder" && (
1265
+ <ExtFolderModal ink={ink} folder={modal.folder} onClose={() => setModal(null)} />
1266
+ )}
1267
+ </View>
1268
+ );
1269
+ }
1270
+
1271
+ function ExtSettingsRow({
1272
+ icon,
1273
+ label,
1274
+ ink,
1275
+ onPress,
1276
+ }: {
1277
+ icon: React.ReactNode;
1278
+ label: string;
1279
+ ink: ReturnType<typeof useInk>;
1280
+ onPress?: () => void;
1281
+ }) {
1282
+ return (
1283
+ <Pressable
1284
+ onPress={onPress}
1285
+ className="flex-row items-center gap-3 rounded-lg border border-border px-3 py-3 active:bg-secondary"
1286
+ >
1287
+ {icon}
1288
+ <Text className="flex-1 text-sm">{label}</Text>
1289
+ <ChevronRight size={16} color={ink.muted} />
1290
+ </Pressable>
1291
+ );
1292
+ }
1293
+
1294
+ // Settings flow (clients/extension SettingsScreen + PasskeyListScreen +
1295
+ // ServerConfigScreen). Owns its own little sub-router so the gear is a full
1296
+ // port: menu → Passkeys → Add passkey, and menu → Server URL.
1297
+ function ExtSettings({ ink, onBack }: { ink: ReturnType<typeof useInk>; onBack: () => void }) {
1298
+ const [sub, setSub] = useState<"menu" | "passkeys" | "enroll" | "server">("menu");
1299
+
1300
+ if (sub === "passkeys") {
1301
+ return <ExtPasskeys ink={ink} onBack={() => setSub("menu")} onAdd={() => setSub("enroll")} />;
1302
+ }
1303
+ if (sub === "enroll") {
1304
+ return <ExtEnroll ink={ink} onBack={() => setSub("passkeys")} />;
1305
+ }
1306
+ if (sub === "server") {
1307
+ return <ExtServerConfig ink={ink} onBack={() => setSub("menu")} />;
1308
+ }
1309
+
1310
+ return (
1311
+ <View className="flex-1 p-5" style={{ gap: 12 } as never}>
1312
+ <View className="flex-row items-center justify-between">
1313
+ <VaultMark />
1314
+ <Pressable onPress={onBack} hitSlop={8} className="active:opacity-60">
1315
+ <Text className="text-[13px] font-medium text-muted-foreground">Done</Text>
1316
+ </Pressable>
1317
+ </View>
1318
+ <Text className="mt-2 text-lg font-semibold">Settings</Text>
1319
+ <ScrollView className="flex-1" contentContainerStyle={{ gap: 8, paddingBottom: 16 } as never}>
1320
+ <ExtSettingsRow
1321
+ ink={ink}
1322
+ icon={<KeyRound size={18} color={ink.fg} strokeWidth={1.75} />}
1323
+ label="Passkeys"
1324
+ onPress={() => setSub("passkeys")}
1325
+ />
1326
+ <ExtSettingsRow
1327
+ ink={ink}
1328
+ icon={<Server size={18} color={ink.fg} strokeWidth={1.75} />}
1329
+ label="Server URL"
1330
+ onPress={() => setSub("server")}
1331
+ />
1332
+ <View style={{ height: 8 } as never} />
1333
+ <Button variant="destructive" onPress={() => {}}>
1334
+ <View className="flex-row items-center justify-center gap-2">
1335
+ <LogOut size={16} color="#ffffff" strokeWidth={2} />
1336
+ <Text className="text-sm font-medium" style={{ color: "#ffffff" } as never}>
1337
+ Sign out
1338
+ </Text>
1339
+ </View>
1340
+ </Button>
1341
+ <Text className="mt-1 text-xs text-muted-foreground">
1342
+ Signing out clears your session token. Your encrypted vault stays on the server.
1343
+ </Text>
1344
+ </ScrollView>
1345
+ </View>
1346
+ );
1347
+ }
1348
+
1349
+ // Passkey management (PasskeyListScreen) — popup directs rename/delete to the
1350
+ // main app behind the MFA gate; "Add passkey" stays one click away.
1351
+ function ExtPasskeys({
1352
+ ink,
1353
+ onBack,
1354
+ onAdd,
1355
+ }: {
1356
+ ink: ReturnType<typeof useInk>;
1357
+ onBack: () => void;
1358
+ onAdd: () => void;
1359
+ }) {
1360
+ return (
1361
+ <View className="flex-1 p-5" style={{ gap: 12 } as never}>
1362
+ <View className="flex-row items-center justify-between">
1363
+ <View className="flex-row items-center gap-2">
1364
+ <Pressable onPress={onBack} hitSlop={8} className="active:opacity-60">
1365
+ <ChevronLeft size={20} color={ink.fg} strokeWidth={1.75} />
1366
+ </Pressable>
1367
+ <VaultMark />
1368
+ </View>
1369
+ <Text className="text-base font-semibold">Passkeys</Text>
1370
+ </View>
1371
+ <ScrollView
1372
+ className="flex-1"
1373
+ contentContainerStyle={{ gap: 16, paddingBottom: 16 } as never}
1374
+ >
1375
+ <View style={{ gap: 8 } as never}>
1376
+ <Button onPress={onAdd}>
1377
+ <View className="flex-row items-center justify-center gap-2">
1378
+ <Plus size={16} color={ink.bg} strokeWidth={2} />
1379
+ <Text className="text-sm font-medium" style={{ color: ink.bg } as never}>
1380
+ Add passkey
1381
+ </Text>
1382
+ </View>
1383
+ </Button>
1384
+ <Text className="text-xs text-muted-foreground">
1385
+ Register a new passkey for this account using your browser&apos;s built-in WebAuthn
1386
+ dialog.
1387
+ </Text>
1388
+ </View>
1389
+ <View className="rounded-lg border border-border p-3" style={{ gap: 8 } as never}>
1390
+ <View className="flex-row items-center gap-2">
1391
+ <ShieldAlert size={16} color={ink.fg} strokeWidth={1.75} />
1392
+ <Text className="flex-1 text-sm font-medium">
1393
+ Rename or remove passkeys in the main app
1394
+ </Text>
1395
+ </View>
1396
+ <Text className="text-xs leading-4 text-muted-foreground">
1397
+ Renaming and deleting passkeys requires multi-factor verification. The extension popup
1398
+ doesn&apos;t surface a TOTP prompt today, so those actions live in the main Vault app
1399
+ (web or mobile).
1400
+ </Text>
1401
+ <View className="mt-1">
1402
+ <Button variant="outline" onPress={() => {}}>
1403
+ <View className="flex-row items-center justify-center gap-2">
1404
+ <ExternalLink size={14} color={ink.fg} strokeWidth={1.75} />
1405
+ <Text className="text-[13px] font-medium">Open the main app</Text>
1406
+ </View>
1407
+ </Button>
1408
+ <Text className="mt-1 text-[11px] text-muted-foreground" numberOfLines={1}>
1409
+ vault.example.com
1410
+ </Text>
1411
+ </View>
1412
+ </View>
1413
+ </ScrollView>
1414
+ </View>
1415
+ );
1416
+ }
1417
+
1418
+ function ExtEnroll({ ink, onBack }: { ink: ReturnType<typeof useInk>; onBack: () => void }) {
1419
+ return (
1420
+ <View className="flex-1">
1421
+ <View className="flex-row items-center gap-2 px-4 py-3">
1422
+ <Pressable onPress={onBack} hitSlop={8} className="active:opacity-60">
1423
+ <ChevronLeft size={20} color={ink.fg} strokeWidth={1.75} />
1424
+ </Pressable>
1425
+ <VaultMark />
1426
+ </View>
1427
+ <ScrollView className="flex-1" contentContainerStyle={{ padding: 16 } as never}>
1428
+ <PasskeyEnroll ink={ink} />
1429
+ </ScrollView>
1430
+ </View>
1431
+ );
1432
+ }
1433
+
1434
+ // Server URL editor (ServerConfigScreen). Test runs a (mocked) reachability
1435
+ // check and shows the same success banner the real screen does.
1436
+ // Real reachability probe — ports api/health.ts. Parses the URL, GETs
1437
+ // `<url>/health`, and only reports success on a JSON `{status:"healthy"}`.
1438
+ // A bad URL or unreachable host genuinely fails (no mock), so a typo can't
1439
+ // "pass". Cross-origin servers without CORS show as unreachable — honest for
1440
+ // a probe fired from the browser.
1441
+ type HealthResult = { ok: boolean; message: string } | null;
1442
+
1443
+ async function probeServer(rawUrl: string): Promise<HealthResult> {
1444
+ const trimmed = rawUrl.trim();
1445
+ let parsed: URL;
1446
+ try {
1447
+ parsed = new URL(trimmed);
1448
+ } catch {
1449
+ return { ok: false, message: "Invalid URL format" };
1450
+ }
1451
+ const target = `${trimmed.replace(/\/+$/, "")}/health`;
1452
+ const ctrl = new AbortController();
1453
+ const timer = setTimeout(() => ctrl.abort(), 8000);
1454
+ try {
1455
+ const res = await fetch(target, { method: "GET", credentials: "omit", signal: ctrl.signal });
1456
+ if (!res.ok) {
1457
+ return { ok: false, message: `Server responded with HTTP ${res.status}` };
1458
+ }
1459
+ let body: { status?: string; version?: string } = {};
1460
+ try {
1461
+ body = (await res.json()) as { status?: string; version?: string };
1462
+ } catch {
1463
+ return { ok: false, message: "Unexpected response body" };
1464
+ }
1465
+ if (body.status !== "healthy") {
1466
+ return { ok: false, message: `Server reported status: ${body.status ?? "unknown"}` };
1467
+ }
1468
+ return {
1469
+ ok: true,
1470
+ message: body.version ? `Server reachable (version ${body.version})` : "Server reachable",
1471
+ };
1472
+ } catch (e) {
1473
+ const reason = e instanceof Error ? e.message : "network error";
1474
+ if (parsed.protocol === "https:" && reason.toLowerCase().includes("certificate")) {
1475
+ return { ok: false, message: "TLS certificate validation failed" };
1476
+ }
1477
+ if (reason.toLowerCase().includes("abort") || reason.toLowerCase().includes("timeout")) {
1478
+ return { ok: false, message: "Request timed out" };
1479
+ }
1480
+ return { ok: false, message: `Could not reach server (${reason})` };
1481
+ } finally {
1482
+ clearTimeout(timer);
1483
+ }
1484
+ }
1485
+
1486
+ function ExtServerConfig({ ink, onBack }: { ink: ReturnType<typeof useInk>; onBack: () => void }) {
1487
+ const DEFAULT_URL = "https://vault.example.com/api";
1488
+ const [url, setUrl] = useState(DEFAULT_URL);
1489
+ const [testing, setTesting] = useState(false);
1490
+ const [result, setResult] = useState<HealthResult>(null);
1491
+
1492
+ async function test() {
1493
+ setTesting(true);
1494
+ setResult(null);
1495
+ const r = await probeServer(url);
1496
+ setResult(r);
1497
+ setTesting(false);
1498
+ }
1499
+
1500
+ return (
1501
+ <ScrollView className="flex-1" contentContainerStyle={{ padding: 24, gap: 16 } as never}>
1502
+ <View className="items-start" style={{ marginBottom: 8 } as never}>
1503
+ <VaultMark />
1504
+ </View>
1505
+ <View style={{ gap: 4, marginBottom: 4 } as never}>
1506
+ <Text className="text-[22px] font-semibold">Server URL</Text>
1507
+ <Text className="text-[13px] text-muted-foreground">
1508
+ Point this extension at your self-hosted Vault server. HTTPS required (loopback may use
1509
+ HTTP).
1510
+ </Text>
1511
+ </View>
1512
+ <ExtField
1513
+ label="URL"
1514
+ placeholder="https://vault.example.com/api"
1515
+ value={url}
1516
+ onChange={(v) => {
1517
+ setUrl(v);
1518
+ setResult(null);
1519
+ }}
1520
+ ink={ink}
1521
+ />
1522
+ {result && (
1523
+ <View
1524
+ className="rounded-lg border p-2.5"
1525
+ style={
1526
+ {
1527
+ borderColor: result.ok ? "rgb(34,197,94)" : ink.danger,
1528
+ backgroundColor: result.ok ? "rgba(34,197,94,0.08)" : "rgba(239,68,68,0.08)",
1529
+ } as never
1530
+ }
1531
+ >
1532
+ <Text
1533
+ className="text-[13px] font-medium"
1534
+ style={{ color: result.ok ? "rgb(34,197,94)" : ink.danger } as never}
1535
+ >
1536
+ {result.ok ? "✓ " : "✗ "}
1537
+ {result.message}
1538
+ </Text>
1539
+ </View>
1540
+ )}
1541
+ <View className="flex-row gap-2">
1542
+ <View className="flex-1">
1543
+ <Button variant="outline" onPress={test} isLoading={testing}>
1544
+ {testing ? "" : "Test"}
1545
+ </Button>
1546
+ </View>
1547
+ <View className="flex-1">
1548
+ <Button onPress={onBack}>Save</Button>
1549
+ </View>
1550
+ </View>
1551
+ <Button
1552
+ variant="ghost"
1553
+ onPress={() => {
1554
+ setUrl(DEFAULT_URL);
1555
+ setResult(null);
1556
+ }}
1557
+ >
1558
+ Reset to default
1559
+ </Button>
1560
+ <Pressable
1561
+ onPress={onBack}
1562
+ className="items-center active:opacity-60"
1563
+ style={{ marginTop: 4 } as never}
1564
+ >
1565
+ <Text className="text-[13px] text-muted-foreground">Back</Text>
1566
+ </Pressable>
1567
+ </ScrollView>
1568
+ );
1569
+ }
1570
+
1571
+ function ExtField({
1572
+ label,
1573
+ placeholder,
1574
+ value,
1575
+ onChange,
1576
+ ink,
1577
+ secret,
1578
+ }: {
1579
+ label: string;
1580
+ placeholder: string;
1581
+ value: string;
1582
+ onChange: (v: string) => void;
1583
+ ink: ReturnType<typeof useInk>;
1584
+ secret?: boolean;
1585
+ }) {
1586
+ const [show, setShow] = useState(false);
1587
+ return (
1588
+ <View style={{ gap: 6 } as never}>
1589
+ <Text className="text-xs text-muted-foreground">{label}</Text>
1590
+ <View
1591
+ className="flex-row items-center rounded-lg border border-border px-3"
1592
+ style={{ height: 40 } as never}
1593
+ >
1594
+ <TextInput
1595
+ value={value}
1596
+ onChangeText={onChange}
1597
+ placeholder={placeholder}
1598
+ placeholderTextColor={ink.muted}
1599
+ secureTextEntry={secret && !show}
1600
+ className="flex-1 text-sm text-foreground"
1601
+ style={{ outlineStyle: "none" } as never}
1602
+ />
1603
+ {secret && (
1604
+ <Pressable onPress={() => setShow((s) => !s)} className="active:opacity-60">
1605
+ {show ? <EyeOff size={16} color={ink.muted} /> : <Eye size={16} color={ink.muted} />}
1606
+ </Pressable>
1607
+ )}
1608
+ </View>
1609
+ </View>
1610
+ );
1611
+ }
1612
+
1613
+ // New / edit login form (clients/extension VaultItemFormScreen). With `item`
1614
+ // the fields prefill and the screen acts as the editor.
1615
+ function ExtItemForm({
1616
+ ink,
1617
+ onCancel,
1618
+ item,
1619
+ }: {
1620
+ ink: ReturnType<typeof useInk>;
1621
+ onCancel: () => void;
1622
+ item?: Item;
1623
+ }) {
1624
+ const acc = item?.accounts[0];
1625
+ const [fav, setFav] = useState(item?.favorite ?? false);
1626
+ const [picked, setPicked] = useState<string[]>(item?.tags.map((t) => t.name) ?? []);
1627
+ const [f, setF] = useState({
1628
+ name: item?.name ?? "",
1629
+ url: item?.url ?? "",
1630
+ label: "",
1631
+ username: acc?.username ?? "",
1632
+ password: acc?.password ?? "",
1633
+ totp: acc?.totp_secret ?? "",
1634
+ notes: acc?.notes ?? "",
1635
+ });
1636
+ const set = (k: keyof typeof f) => (v: string) => setF((s) => ({ ...s, [k]: v }));
1637
+ const allTags = Object.values(T);
1638
+ return (
1639
+ <View className="flex-1">
1640
+ <View className="flex-row items-center justify-between border-b border-border px-4 py-3">
1641
+ <Pressable onPress={onCancel} className="active:opacity-70">
1642
+ <ArrowLeft size={20} color={ink.fg} />
1643
+ </Pressable>
1644
+ <Text className="text-base font-bold">{item ? "Edit item" : "New item"}</Text>
1645
+ <Pressable onPress={() => setFav((v) => !v)} className="active:opacity-70">
1646
+ <Star
1647
+ size={20}
1648
+ color={fav ? ink.star : ink.muted}
1649
+ fill={fav ? ink.star : "transparent"}
1650
+ />
1651
+ </Pressable>
1652
+ </View>
1653
+ <ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 14 } as never}>
1654
+ <ExtField
1655
+ label="Name"
1656
+ placeholder="e.g. Google"
1657
+ value={f.name}
1658
+ onChange={set("name")}
1659
+ ink={ink}
1660
+ />
1661
+ <ExtField
1662
+ label="URL"
1663
+ placeholder="https://example.com"
1664
+ value={f.url}
1665
+ onChange={set("url")}
1666
+ ink={ink}
1667
+ />
1668
+ <View style={{ gap: 6 } as never}>
1669
+ <Text className="text-xs text-muted-foreground">Tags</Text>
1670
+ <View className="flex-row flex-wrap gap-1.5">
1671
+ {allTags.map((t) => {
1672
+ const on = picked.includes(t.name);
1673
+ const Icon = t.icon === "circle" ? CircleIcon : t.icon === "star" ? Star : TagIcon;
1674
+ return (
1675
+ <Pressable
1676
+ key={t.name}
1677
+ onPress={() =>
1678
+ setPicked((s) => (on ? s.filter((x) => x !== t.name) : [...s, t.name]))
1679
+ }
1680
+ className="flex-row items-center gap-1 rounded-md border px-2 py-1"
1681
+ style={
1682
+ {
1683
+ borderColor: `${t.color}${on ? "" : "55"}`,
1684
+ backgroundColor: on ? `${t.color}22` : "transparent",
1685
+ } as never
1686
+ }
1687
+ >
1688
+ <Icon size={11} color={t.color} />
1689
+ <Text className="text-xs">{t.name}</Text>
1690
+ </Pressable>
1691
+ );
1692
+ })}
1693
+ </View>
1694
+ </View>
1695
+ <View className="rounded-lg border border-border bg-card p-3" style={{ gap: 12 } as never}>
1696
+ <ExtField
1697
+ label="Label"
1698
+ placeholder="e.g. Personal, Work"
1699
+ value={f.label}
1700
+ onChange={set("label")}
1701
+ ink={ink}
1702
+ />
1703
+ <ExtField
1704
+ label="Username"
1705
+ placeholder="you@example.com"
1706
+ value={f.username}
1707
+ onChange={set("username")}
1708
+ ink={ink}
1709
+ />
1710
+ <ExtField
1711
+ label="Password"
1712
+ placeholder="••••••••"
1713
+ value={f.password}
1714
+ onChange={set("password")}
1715
+ ink={ink}
1716
+ secret
1717
+ />
1718
+ <ExtField
1719
+ label="TOTP secret (base32)"
1720
+ placeholder="JBSWY3DPEHPK3PXP"
1721
+ value={f.totp}
1722
+ onChange={set("totp")}
1723
+ ink={ink}
1724
+ />
1725
+ <ExtField
1726
+ label="Notes"
1727
+ placeholder="Optional"
1728
+ value={f.notes}
1729
+ onChange={set("notes")}
1730
+ ink={ink}
1731
+ />
1732
+ </View>
1733
+ <Pressable
1734
+ onPress={() => {}}
1735
+ className="flex-row items-center justify-center gap-2 rounded-lg border border-border py-2.5 active:opacity-80"
1736
+ >
1737
+ <Plus size={16} color={ink.fg} />
1738
+ <Text className="text-sm font-medium">Add account</Text>
1739
+ </Pressable>
1740
+ <View className="flex-row gap-2 pt-1">
1741
+ <View className="flex-1">
1742
+ <Button variant="outline" onPress={onCancel}>
1743
+ Cancel
1744
+ </Button>
1745
+ </View>
1746
+ <View className="flex-1">
1747
+ <Button onPress={onCancel}>{item ? "Save changes" : "Create"}</Button>
1748
+ </View>
1749
+ </View>
1750
+ </ScrollView>
1751
+ </View>
1752
+ );
1753
+ }
1754
+
1755
+ function ExtModal({
1756
+ title,
1757
+ ink,
1758
+ children,
1759
+ }: {
1760
+ title: string;
1761
+ ink: ReturnType<typeof useInk>;
1762
+ children: React.ReactNode;
1763
+ }) {
1764
+ void ink;
1765
+ return (
1766
+ <View
1767
+ className="absolute inset-0 z-50 items-center justify-center p-5"
1768
+ style={{ backgroundColor: "rgba(0,0,0,0.4)" } as never}
1769
+ >
1770
+ <View
1771
+ className="w-full rounded-xl border border-border bg-card p-4"
1772
+ style={{ maxWidth: 320, gap: 14 } as never}
1773
+ >
1774
+ <Text className="text-sm font-bold">{title}</Text>
1775
+ {children}
1776
+ </View>
1777
+ </View>
1778
+ );
1779
+ }
1780
+
1781
+ const TAG_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#a855f7", "#06b6d4", "#ef4444"];
1782
+
1783
+ function ExtTagModal({
1784
+ ink,
1785
+ onClose,
1786
+ tag,
1787
+ }: {
1788
+ ink: ReturnType<typeof useInk>;
1789
+ onClose: () => void;
1790
+ tag?: DTag;
1791
+ }) {
1792
+ const [name, setName] = useState(tag?.name ?? "");
1793
+ const [color, setColor] = useState(tag?.color ?? TAG_COLORS[0]);
1794
+ return (
1795
+ <ExtModal title={tag ? "Edit tag" : "New tag"} ink={ink}>
1796
+ <ExtField label="Name" placeholder="Personal" value={name} onChange={setName} ink={ink} />
1797
+ <View style={{ gap: 6 } as never}>
1798
+ <Text className="text-xs text-muted-foreground">Color</Text>
1799
+ <View className="flex-row gap-2">
1800
+ {TAG_COLORS.map((c) => (
1801
+ <Pressable
1802
+ key={c}
1803
+ onPress={() => setColor(c)}
1804
+ style={
1805
+ {
1806
+ width: 28,
1807
+ height: 28,
1808
+ borderRadius: 999,
1809
+ backgroundColor: c,
1810
+ borderWidth: color === c ? 2 : 0,
1811
+ borderColor: ink.fg,
1812
+ } as never
1813
+ }
1814
+ />
1815
+ ))}
1816
+ </View>
1817
+ </View>
1818
+ <View className="flex-row gap-2">
1819
+ <View className="flex-1">
1820
+ <Button variant="outline" onPress={onClose}>
1821
+ Cancel
1822
+ </Button>
1823
+ </View>
1824
+ <View className="flex-1">
1825
+ <Button onPress={onClose}>{tag ? "Save" : "Create"}</Button>
1826
+ </View>
1827
+ </View>
1828
+ </ExtModal>
1829
+ );
1830
+ }
1831
+
1832
+ function ExtFolderModal({
1833
+ ink,
1834
+ onClose,
1835
+ folder,
1836
+ }: {
1837
+ ink: ReturnType<typeof useInk>;
1838
+ onClose: () => void;
1839
+ folder?: DFolder;
1840
+ }) {
1841
+ const [name, setName] = useState(folder?.name ?? "");
1842
+ return (
1843
+ <ExtModal title={folder ? "Edit folder" : "New folder"} ink={ink}>
1844
+ <ExtField label="Name" placeholder="Work" value={name} onChange={setName} ink={ink} />
1845
+ <View className="flex-row gap-2">
1846
+ <View className="flex-1">
1847
+ <Button variant="outline" onPress={onClose}>
1848
+ Cancel
1849
+ </Button>
1850
+ </View>
1851
+ <View className="flex-1">
1852
+ <Button onPress={onClose}>{folder ? "Save" : "Create"}</Button>
1853
+ </View>
1854
+ </View>
1855
+ </ExtModal>
1856
+ );
1857
+ }
1858
+
1859
+ type ExtTab = "vault" | "tags" | "send" | "secrets";
1860
+ export type ExtRoute =
1861
+ | { r: "main" }
1862
+ | { r: "detail"; id: string }
1863
+ | { r: "settings" }
1864
+ | { r: "new" };
1865
+
1866
+ // External controller — lets a parent scene drive the popup (route, the
1867
+ // highlighted row, the "copied" field, and the list/detail scroll positions)
1868
+ // instead of the popup's own internal autoplay. Used by TotpScene so a single
1869
+ // scene-level cursor can hand the code off to a browser window.
1870
+ function PasskeyEnroll({ ink }: { ink: ReturnType<typeof useInk> }) {
1871
+ const [label, setLabel] = useState("My iPhone");
1872
+ return (
1873
+ <Card
1874
+ className="w-full max-w-[480px] self-center"
1875
+ style={vaultVars(ink.fg === "#ffffff") as never}
1876
+ >
1877
+ <CardHeader className="items-center">
1878
+ <View className="mb-3 h-16 w-16 items-center justify-center rounded-full bg-secondary">
1879
+ <KeyRound size={30} color={ink.fg} />
1880
+ </View>
1881
+ <CardTitle className="text-center">Add a passkey</CardTitle>
1882
+ <CardDescription className="text-center">
1883
+ Passkeys are a faster, phishing-resistant way to sign in. Use Face ID, Touch ID, or a
1884
+ hardware key.
1885
+ </CardDescription>
1886
+ </CardHeader>
1887
+ <CardContent className="gap-4">
1888
+ <View className="flex-row items-start gap-2 rounded-lg bg-secondary p-3">
1889
+ <View className="mt-0.5">
1890
+ <Fingerprint size={16} color={ink.muted} />
1891
+ </View>
1892
+ <MutedText className="flex-1 text-xs leading-4">
1893
+ Your passkey stays on this device (or in iCloud Keychain / Google Password Manager).
1894
+ Vault never sees the private key.
1895
+ </MutedText>
1896
+ </View>
1897
+ <View className="gap-1.5">
1898
+ <Text className="text-xs text-muted-foreground">Passkey name</Text>
1899
+ <View className="rounded-lg border border-border px-3 py-2.5">
1900
+ <TextInput
1901
+ value={label}
1902
+ onChangeText={setLabel}
1903
+ placeholder="My iPhone"
1904
+ placeholderTextColor={ink.muted}
1905
+ className="text-sm text-foreground"
1906
+ style={{ outlineStyle: "none" } as never}
1907
+ />
1908
+ </View>
1909
+ <MutedText className="text-xs">
1910
+ Used to identify this passkey in your settings list.
1911
+ </MutedText>
1912
+ </View>
1913
+ </CardContent>
1914
+ <CardFooter className="flex-col gap-2 border-t-0">
1915
+ <Button className="w-full">Create passkey</Button>
1916
+ </CardFooter>
1917
+ </Card>
1918
+ );
1919
+ }
1920
+
1921
+ // ── embedded single demo (registry) ───────────────────────────────────────────
1922
+
1923
+ /**
1924
+ * A parent-driven view of the popup.
1925
+ *
1926
+ * Lets a scene puppet the popup — scroll the list, highlight a row, open it,
1927
+ * show a field as copied — while the popup keeps owning how any of that looks.
1928
+ * All of it is RN-safe, which is why it survived the move.
1929
+ *
1930
+ * What did NOT survive is the original's self-driving `auto` mode: it steered a
1931
+ * synthetic cursor with `querySelector` and `getBoundingClientRect`, which have
1932
+ * no meaning on native. A scene that wants that keeps its own cursor and drives
1933
+ * this controller instead.
1934
+ */
1935
+ export interface ExtensionPopupController {
1936
+ route: ExtRoute;
1937
+ highlightId: string | null;
1938
+ autoCopied: string | null;
1939
+ listScrollRef: React.Ref<ScrollView>;
1940
+ detailScrollRef: React.Ref<ScrollView>;
1941
+ }
1942
+
1943
+ /**
1944
+ * The browser-extension popup.
1945
+ *
1946
+ * Interactive by default. Pass `external` to have a parent scene drive it.
1947
+ */
1948
+ export function ExtensionPopup({
1949
+ logo,
1950
+ external,
1951
+ height = EXTENSION_POPUP_HEIGHT,
1952
+ }: {
1953
+ logo?: React.ReactNode;
1954
+ external?: ExtensionPopupController;
1955
+ /** Frame height. Defaults to the full 600; see `EXTENSION_POPUP_HEIGHT`. */
1956
+ height?: number;
1957
+ } = {}) {
1958
+ const ink = useInk();
1959
+ const dark = ink.fg === "#ffffff";
1960
+ const [tab, setTab] = useState<ExtTab>("vault");
1961
+ const [route, setRoute] = useState<ExtRoute>({ r: "main" });
1962
+ const [modal, setModal] = useState<"tag" | "folder" | null>(null);
1963
+ const [items, setItems] = useState<Item[]>(ITEMS);
1964
+ const driven = !!external;
1965
+ const vroute = external ? external.route : route;
1966
+ const openItem = vroute.r === "detail" ? (items.find((i) => i.id === vroute.id) ?? null) : null;
1967
+
1968
+ return (
1969
+ <View
1970
+ className="relative self-center overflow-hidden rounded-xl border border-border bg-background"
1971
+ style={[vaultVars(dark), { width: EXTENSION_POPUP_WIDTH, height }] as never}
1972
+ // A driven popup is a picture, not a control: taps would fight the scene.
1973
+ pointerEvents={driven ? "none" : "auto"}
1974
+ >
1975
+ {vroute.r === "detail" && openItem ? (
1976
+ <ItemDetail
1977
+ item={openItem}
1978
+ ink={ink}
1979
+ onBack={() => setRoute({ r: "main" })}
1980
+ onDelete={() => {
1981
+ const id = openItem.id;
1982
+ setItems((s) => s.filter((i) => i.id !== id));
1983
+ setRoute({ r: "main" });
1984
+ }}
1985
+ forceCopiedLabel={driven ? external?.autoCopied : undefined}
1986
+ scrollRef={external?.detailScrollRef}
1987
+ />
1988
+ ) : vroute.r === "settings" ? (
1989
+ <ExtSettings ink={ink} onBack={() => setRoute({ r: "main" })} />
1990
+ ) : vroute.r === "new" ? (
1991
+ <ExtItemForm ink={ink} onCancel={() => setRoute({ r: "main" })} />
1992
+ ) : (
1993
+ <View className="flex-1">
1994
+ {/* Vault + Tags tabs show the logo header (matches the app); Send and
1995
+ Secrets are full-screen "coming soon" with no header. */}
1996
+ {(tab === "vault" || tab === "tags") && (
1997
+ <ExtHeader
1998
+ logo={logo}
1999
+ tab={tab}
2000
+ ink={ink}
2001
+ onAdd={() => (tab === "tags" ? setModal("tag") : setRoute({ r: "new" }))}
2002
+ onAddFolder={() => setModal("folder")}
2003
+ onSettings={() => setRoute({ r: "settings" })}
2004
+ />
2005
+ )}
2006
+
2007
+ <View className="flex-1">
2008
+ {tab === "vault" && (
2009
+ <VaultList
2010
+ ink={ink}
2011
+ items={items}
2012
+ onOpen={(id) => setRoute({ r: "detail", id })}
2013
+ scrollRef={external?.listScrollRef}
2014
+ highlightId={external?.highlightId}
2015
+ />
2016
+ )}
2017
+ {tab === "tags" && <ExtTagsTab ink={ink} />}
2018
+ {tab === "send" && (
2019
+ <ExtComingSoon
2020
+ ink={ink}
2021
+ icon={<Send size={28} color={ink.muted} />}
2022
+ desc="Send files securely via link or email with permissions, auto-expiry, and encryption."
2023
+ />
2024
+ )}
2025
+ {tab === "secrets" && (
2026
+ <ExtComingSoon
2027
+ ink={ink}
2028
+ icon={<KeyRound size={28} color={ink.muted} />}
2029
+ desc="Manage API keys, tokens, environment variables, and other developer secrets."
2030
+ />
2031
+ )}
2032
+ </View>
2033
+
2034
+ <ExtBottomNav
2035
+ active={tab}
2036
+ onChange={(t) => {
2037
+ setTab(t);
2038
+ setRoute({ r: "main" });
2039
+ }}
2040
+ ink={ink}
2041
+ />
2042
+
2043
+ {modal === "tag" && <ExtTagModal ink={ink} onClose={() => setModal(null)} />}
2044
+ {modal === "folder" && <ExtFolderModal ink={ink} onClose={() => setModal(null)} />}
2045
+ </View>
2046
+ )}
2047
+ </View>
2048
+ );
2049
+ }
2050
+
2051
+ export default ExtensionPopup;