@entropy-softworks/ui 2026.8.35 → 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 (44) hide show
  1. package/lib/components/auth/PasswordStrengthMeter.d.ts +9 -0
  2. package/lib/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
  3. package/lib/components/auth/PasswordStrengthMeter.jsx +10 -4
  4. package/lib/components/auth/PasswordStrengthMeter.jsx.map +1 -1
  5. package/lib/components/profile/SecurityTab.d.ts +5 -1
  6. package/lib/components/profile/SecurityTab.d.ts.map +1 -1
  7. package/lib/components/profile/SecurityTab.jsx.map +1 -1
  8. package/lib/components/segmented-toggle.d.ts +9 -0
  9. package/lib/components/segmented-toggle.d.ts.map +1 -1
  10. package/lib/components/segmented-toggle.jsx +4 -0
  11. package/lib/components/segmented-toggle.jsx.map +1 -1
  12. package/lib/components/social-connections.d.ts +22 -5
  13. package/lib/components/social-connections.d.ts.map +1 -1
  14. package/lib/components/social-connections.jsx +37 -11
  15. package/lib/components/social-connections.jsx.map +1 -1
  16. package/lib/context/AppConfigContext.d.ts +27 -0
  17. package/lib/context/AppConfigContext.d.ts.map +1 -1
  18. package/lib/context/AppConfigContext.jsx.map +1 -1
  19. package/lib/demos/extension-popup.d.ts +212 -0
  20. package/lib/demos/extension-popup.d.ts.map +1 -0
  21. package/lib/demos/extension-popup.jsx +1172 -0
  22. package/lib/demos/extension-popup.jsx.map +1 -0
  23. package/lib/demos/index.d.ts +11 -0
  24. package/lib/demos/index.d.ts.map +1 -0
  25. package/lib/demos/index.js +14 -0
  26. package/lib/demos/index.js.map +1 -0
  27. package/lib/screens/auth/AuthScreen.d.ts +75 -1
  28. package/lib/screens/auth/AuthScreen.d.ts.map +1 -1
  29. package/lib/screens/auth/AuthScreen.jsx +356 -122
  30. package/lib/screens/auth/AuthScreen.jsx.map +1 -1
  31. package/lib/services/auth.service.d.ts +5 -1
  32. package/lib/services/auth.service.d.ts.map +1 -1
  33. package/lib/services/auth.service.js +5 -1
  34. package/lib/services/auth.service.js.map +1 -1
  35. package/package.json +7 -1
  36. package/src/components/auth/PasswordStrengthMeter.tsx +25 -3
  37. package/src/components/profile/SecurityTab.tsx +11 -1
  38. package/src/components/segmented-toggle.tsx +13 -0
  39. package/src/components/social-connections.tsx +66 -20
  40. package/src/context/AppConfigContext.tsx +27 -0
  41. package/src/demos/extension-popup.tsx +2051 -0
  42. package/src/demos/index.ts +38 -0
  43. package/src/screens/auth/AuthScreen.tsx +701 -312
  44. package/src/services/auth.service.ts +13 -2
@@ -0,0 +1,1172 @@
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
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
26
+ import { Image, Linking, Pressable, ScrollView, Text as RNText, TextInput, View, } from "react-native";
27
+ import { useColorScheme, vars } from "nativewind";
28
+ 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";
29
+ import { MutedText, Text } from "../components/text";
30
+ import { Button } from "../components/button";
31
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "../components/card";
32
+ /**
33
+ * The real popup's frame, in px.
34
+ *
35
+ * `HEIGHT` is Chrome's *maximum* for a browser action popup, not a fixed size —
36
+ * the real popup is as tall as it needs to be, up to this. So a host with less
37
+ * vertical room may render it shorter (see the `height` prop) without the thing
38
+ * on screen becoming a lie about what the extension looks like; the item list
39
+ * scrolls inside, exactly as it does at full height.
40
+ */
41
+ export const EXTENSION_POPUP_WIDTH = 380;
42
+ export const EXTENSION_POPUP_HEIGHT = 600;
43
+ /** Below this the chrome crowds out the list and it stops reading as the popup. */
44
+ export const EXTENSION_POPUP_MIN_HEIGHT = 320;
45
+ export function useInk() {
46
+ const { colorScheme: scheme } = useColorScheme();
47
+ const dark = scheme === "dark";
48
+ // Vault's own palette (pure white in light), matching .vault-surface.
49
+ return {
50
+ fg: dark ? "#ffffff" : "#000000",
51
+ muted: dark ? "#a3a3a3" : "#737373",
52
+ bg: dark ? "#000000" : "#ffffff",
53
+ border: dark ? "#262626" : "#e5e5e5",
54
+ secondary: dark ? "#262626" : "#f5f5f5",
55
+ danger: "#ef4444",
56
+ star: "#f59e0b",
57
+ };
58
+ }
59
+ // Demo graphics are react-native-web, which SSRs markup that then re-lays-out
60
+ // on hydration ("pops in") and would render with the pre-mount theme guess
61
+ // (a dark flash on a light page). Gate them to mount client-side, behind a
62
+ // theme-correct placeholder, so they appear once, already in the right theme.
63
+ export function vaultVars(dark) {
64
+ return vars(dark
65
+ ? {
66
+ "--background-rgb": "0 0 0",
67
+ "--foreground-rgb": "255 255 255",
68
+ "--card": "#0a0a0a",
69
+ "--card-foreground": "#ffffff",
70
+ "--secondary": "#262626",
71
+ "--secondary-foreground": "#ffffff",
72
+ "--muted": "#262626",
73
+ "--muted-foreground": "#a3a3a3",
74
+ "--border": "#262626",
75
+ "--primary": "#ffffff",
76
+ "--primary-foreground": "#000000",
77
+ }
78
+ : {
79
+ "--background-rgb": "255 255 255",
80
+ "--foreground-rgb": "0 0 0",
81
+ "--card": "#ffffff",
82
+ "--card-foreground": "#000000",
83
+ "--secondary": "#f5f5f5",
84
+ "--secondary-foreground": "#000000",
85
+ "--muted": "#f5f5f5",
86
+ "--muted-foreground": "#737373",
87
+ "--border": "#e5e5e5",
88
+ "--primary": "#000000",
89
+ "--primary-foreground": "#ffffff",
90
+ });
91
+ }
92
+ export const T = {
93
+ social: { name: "social", color: "#3b82f6", icon: "circle" },
94
+ shopping: { name: "shopping", color: "#22c55e", icon: "tag" },
95
+ finance: { name: "finance", color: "#f59e0b", icon: "star" },
96
+ streaming: { name: "streaming", color: "#a855f7", icon: "circle" },
97
+ gaming: { name: "gaming", color: "#06b6d4", icon: "tag" },
98
+ };
99
+ export const ITEMS = [
100
+ {
101
+ id: "1",
102
+ name: "Netflix",
103
+ url: "https://netflix.com",
104
+ favorite: true,
105
+ tags: [T.streaming],
106
+ created: "2024-02-11",
107
+ updated: "2025-05-30",
108
+ accounts: [
109
+ {
110
+ username: "example@entropysoftworks.com",
111
+ password: "tR0ub4dour&3xplsion",
112
+ totp_secret: "JBSWY3DPEHPK3PXP",
113
+ notes: "Shared family plan.",
114
+ },
115
+ ],
116
+ },
117
+ {
118
+ id: "2",
119
+ name: "Amazon",
120
+ url: "https://amazon.com",
121
+ favorite: true,
122
+ tags: [T.shopping],
123
+ created: "2024-01-04",
124
+ updated: "2025-05-22",
125
+ accounts: [
126
+ {
127
+ username: "example@entropysoftworks.com",
128
+ password: "correct-horse-battery",
129
+ totp_secret: "KRSXG5CTMVRXEZLU",
130
+ },
131
+ ],
132
+ },
133
+ {
134
+ id: "3",
135
+ name: "Instagram",
136
+ url: "https://instagram.com",
137
+ favorite: true,
138
+ tags: [T.social],
139
+ created: "2024-03-02",
140
+ updated: "2025-06-01",
141
+ accounts: [
142
+ { username: "yourhandle", password: "9xQvM2pL7wE4z", totp_secret: "MFRGGZDFMZTWQ2LK" },
143
+ ],
144
+ },
145
+ {
146
+ id: "4",
147
+ name: "PayPal",
148
+ url: "https://paypal.com",
149
+ favorite: false,
150
+ tags: [T.finance],
151
+ created: "2024-04-20",
152
+ updated: "2025-05-12",
153
+ accounts: [
154
+ {
155
+ username: "example@entropysoftworks.com",
156
+ password: "Zx8kLp2qWn5vR",
157
+ totp_secret: "GEZDGNBVGY3TQOJQ",
158
+ },
159
+ ],
160
+ },
161
+ {
162
+ id: "5",
163
+ name: "Chase",
164
+ url: "https://chase.com",
165
+ favorite: false,
166
+ tags: [T.finance],
167
+ created: "2024-02-18",
168
+ updated: "2025-04-28",
169
+ accounts: [
170
+ {
171
+ username: "example@entropysoftworks.com",
172
+ password: "Sf6!nQ9xK3mD2p",
173
+ notes: "Checking + card.",
174
+ },
175
+ ],
176
+ },
177
+ {
178
+ id: "6",
179
+ name: "Spotify",
180
+ url: "https://spotify.com",
181
+ favorite: false,
182
+ tags: [T.streaming],
183
+ created: "2024-05-30",
184
+ updated: "2025-03-19",
185
+ accounts: [{ username: "example@entropysoftworks.com", password: "Vb7nQ9xK3mD2p" }],
186
+ },
187
+ {
188
+ id: "7",
189
+ name: "Steam",
190
+ url: "https://steampowered.com",
191
+ favorite: false,
192
+ tags: [T.gaming],
193
+ created: "2024-06-01",
194
+ updated: "2025-02-09",
195
+ accounts: [{ username: "yourhandle", password: "Lr4!tWp2qZ", totp_secret: "NB2W45DFOIZA" }],
196
+ },
197
+ {
198
+ id: "8",
199
+ name: "Reddit",
200
+ url: "https://reddit.com",
201
+ favorite: false,
202
+ tags: [T.social],
203
+ created: "2024-01-22",
204
+ updated: "2025-01-15",
205
+ accounts: [{ username: "u/yourhandle", password: "Dx2!mNp9qR" }],
206
+ },
207
+ ];
208
+ export const SORT_OPTIONS = [
209
+ {
210
+ value: "name_asc",
211
+ label: "Name (A-Z)",
212
+ cmp: (a, b) => a.name.localeCompare(b.name),
213
+ },
214
+ {
215
+ value: "name_desc",
216
+ label: "Name (Z-A)",
217
+ cmp: (a, b) => b.name.localeCompare(a.name),
218
+ },
219
+ {
220
+ value: "updated_newest",
221
+ label: "Recently updated",
222
+ cmp: (a, b) => b.updated.localeCompare(a.updated),
223
+ },
224
+ {
225
+ value: "created_newest",
226
+ label: "Newest first",
227
+ cmp: (a, b) => b.created.localeCompare(a.created),
228
+ },
229
+ {
230
+ value: "created_oldest",
231
+ label: "Oldest first",
232
+ cmp: (a, b) => a.created.localeCompare(b.created),
233
+ },
234
+ ];
235
+ /** The sort used when an index somehow falls outside SORT_OPTIONS. */
236
+ const DEFAULT_SORT = SORT_OPTIONS[0];
237
+ // ── helpers ───────────────────────────────────────────────────────────────
238
+ export function host(url) {
239
+ return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
240
+ }
241
+ // Real ItemIcon renders the site favicon; reproduce with the same favicon
242
+ // service the app's icon resolver falls back to.
243
+ export function favicon(url) {
244
+ return `https://www.google.com/s2/favicons?domain=${host(url)}&sz=64`;
245
+ }
246
+ export function ItemIcon({ url, size = 40, radius = 8, }) {
247
+ return (<View style={{
248
+ width: size,
249
+ height: size,
250
+ borderRadius: radius,
251
+ overflow: "hidden",
252
+ alignItems: "center",
253
+ justifyContent: "center",
254
+ backgroundColor: "#ffffff",
255
+ borderWidth: 1,
256
+ borderColor: "#e5e5e5",
257
+ }}>
258
+ {/* RN `Image`, not a DOM `<img>`: this component renders on native too,
259
+ where `<img>` is not a thing. `resizeMode="contain"` is the RN
260
+ spelling of `object-fit: contain`. */}
261
+ <Image source={{ uri: favicon(url) }} accessibilityIgnoresInvertColors style={{ width: size * 0.6, height: size * 0.6 }} resizeMode="contain"/>
262
+ </View>);
263
+ }
264
+ export function useCopy() {
265
+ const [copied, setCopied] = useState(null);
266
+ const copy = useCallback((key, text) => {
267
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
268
+ navigator.clipboard.writeText(text).catch(() => { });
269
+ }
270
+ setCopied(key);
271
+ setTimeout(() => setCopied((k) => (k === key ? null : k)), 1300);
272
+ }, []);
273
+ return { copied, copy };
274
+ }
275
+ // Deterministic 6-digit code that rolls every 30s (stand-in for real TOTP).
276
+ export function totpCode(seed, step) {
277
+ let h = 0;
278
+ for (const c of seed) {
279
+ h = (h * 31 + c.charCodeAt(0)) >>> 0;
280
+ }
281
+ return Math.abs((h ^ (step * 2654435761)) % 1_000_000)
282
+ .toString()
283
+ .padStart(6, "0");
284
+ }
285
+ export function useTotp(secret) {
286
+ const [now, setNow] = useState(0);
287
+ useEffect(() => {
288
+ const t = setInterval(() => setNow(Date.now()), 1000);
289
+ setNow(Date.now());
290
+ return () => clearInterval(t);
291
+ }, []);
292
+ if (!secret || !now) {
293
+ return { code: "------", remaining: 30 };
294
+ }
295
+ const epoch = Math.floor(now / 1000);
296
+ return { code: totpCode(secret, Math.floor(epoch / 30)), remaining: 30 - (epoch % 30) };
297
+ }
298
+ // Matches the app's RingCountdown widget.
299
+ export function RingCountdown({ remaining, size = 48, period = 30, color, }) {
300
+ const r = size / 2 - 3;
301
+ const c = 2 * Math.PI * r;
302
+ const frac = remaining / period;
303
+ const warn = remaining <= 5;
304
+ const stroke = warn ? "#ef4444" : color;
305
+ return (<View style={{ width: size, height: size }}>
306
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
307
+ <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeOpacity={0.2} strokeWidth={3}/>
308
+ <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={stroke} strokeWidth={3} strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} transform={`rotate(-90 ${size / 2} ${size / 2})`}/>
309
+ <text x={size / 2} y={size / 2 + 4} textAnchor="middle" fontSize={12} fontFamily="var(--font-geist-mono)" fill={stroke}>
310
+ {remaining}
311
+ </text>
312
+ </svg>
313
+ </View>);
314
+ }
315
+ // ── phone frame ─────────────────────────────────────────────────────────────
316
+ export function VaultItemRow({ item, ink, onPress, highlight, }) {
317
+ // Sample data always has one; the guard is for the type checker, and
318
+ // rendering nothing beats crashing if that ever stops being true.
319
+ const acc = item.accounts[0];
320
+ return (<Pressable nativeID={`vrow-${item.id}`} onPress={onPress} className="mb-1.5 flex-row items-center overflow-hidden rounded-lg bg-card p-3.5 active:opacity-80" style={highlight ? { backgroundColor: ink.secondary } : undefined}>
321
+ <View className="mr-3">
322
+ <ItemIcon url={item.url}/>
323
+ </View>
324
+ <View className="flex-1">
325
+ <Text className="text-sm font-semibold" numberOfLines={1}>
326
+ {item.name}
327
+ </Text>
328
+ <MutedText className="text-xs" numberOfLines={1}>
329
+ {acc?.username || host(item.url)}
330
+ </MutedText>
331
+ </View>
332
+ {item.favorite && <Star size={14} color={ink.fg} fill={ink.fg}/>}
333
+ <Pressable className="ml-1 px-1.5 py-1 active:opacity-70" onPress={() => void Linking.openURL(item.url).catch(() => undefined)}>
334
+ <ExternalLink size={18} color={ink.muted}/>
335
+ </Pressable>
336
+ <ChevronRight size={18} color={ink.muted}/>
337
+ </Pressable>);
338
+ }
339
+ export function Dropdown({ open, onToggle, label, icon, children, ink, }) {
340
+ return (<View style={{ position: "relative" }}>
341
+ <Pressable onPress={onToggle} className="flex-row items-center gap-1 rounded-md border border-border px-2 py-1 active:opacity-70">
342
+ {icon}
343
+ <MutedText className="text-xs">{label}</MutedText>
344
+ {open ? (<ChevronUp size={10} color={ink.muted}/>) : (<ChevronDown size={10} color={ink.muted}/>)}
345
+ </Pressable>
346
+ {open && (<View className="absolute right-0 top-8 z-50 rounded-lg border border-border bg-card py-1" style={{ minWidth: 160 }}>
347
+ {children}
348
+ </View>)}
349
+ </View>);
350
+ }
351
+ export function VaultList({ ink, onOpen, items = ITEMS, scrollRef, highlightId, }) {
352
+ const [query, setQuery] = useState("");
353
+ const [sortIdx, setSortIdx] = useState(0);
354
+ const [showSort, setShowSort] = useState(false);
355
+ // `?? SORT_OPTIONS[0]!` rather than a non-null assertion on the index:
356
+ // sortIdx is driven by the picker below and cannot leave range, but under
357
+ // noUncheckedIndexedAccess the compiler cannot know that, and falling back to
358
+ // the default sort is the right behaviour if it ever did.
359
+ // Falls back to the first option rather than asserting non-null: sortIdx is
360
+ // driven by the picker and cannot leave range, but the default sort is the
361
+ // right behaviour if it ever did.
362
+ const sort = SORT_OPTIONS[sortIdx] ?? DEFAULT_SORT;
363
+ const filtered = useMemo(() => {
364
+ const q = query.trim().toLowerCase();
365
+ return items.filter((i) => !q ||
366
+ i.name.toLowerCase().includes(q) ||
367
+ i.accounts.some((a) => a.username.toLowerCase().includes(q)) ||
368
+ host(i.url).includes(q));
369
+ }, [query, items]);
370
+ const favorites = filtered.filter((i) => i.favorite);
371
+ const all = [...filtered.filter((i) => !i.favorite)].sort(sort.cmp);
372
+ return (<View className="flex-1">
373
+ <View className="px-4 pb-2 pt-1">
374
+ <View className="flex-row items-center rounded-lg border border-border px-3" style={{ height: 40 }}>
375
+ <Search size={18} color={ink.muted}/>
376
+ <TextInput value={query} onChangeText={setQuery} placeholder="Search passwords" placeholderTextColor={ink.muted} className="ml-2 flex-1 text-sm text-foreground" style={{ outlineStyle: "none" }}/>
377
+ </View>
378
+ </View>
379
+
380
+ <ScrollView ref={scrollRef} className="flex-1" contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }}>
381
+ {favorites.length > 0 && (<View className="pt-2">
382
+ <View className="mb-2 flex-row items-center gap-1.5 px-1">
383
+ <Star size={14} color={ink.fg}/>
384
+ <Text className="text-xs font-semibold uppercase tracking-wider">Favorites</Text>
385
+ <MutedText className="text-xs">({favorites.length})</MutedText>
386
+ </View>
387
+ {favorites.map((i) => (<VaultItemRow key={i.id} item={i} ink={ink} onPress={() => onOpen(i.id)} highlight={highlightId === i.id}/>))}
388
+ <View className="my-3 h-px bg-border"/>
389
+ </View>)}
390
+
391
+ {all.length > 0 && (<View style={{ zIndex: 50 }}>
392
+ <View className="relative mb-2 flex-row items-center justify-between px-1" style={{ zIndex: 50 }}>
393
+ <View className="flex-row items-center gap-1.5">
394
+ <Text className="text-xs font-semibold uppercase tracking-wider">All Items</Text>
395
+ <MutedText className="text-xs">({all.length})</MutedText>
396
+ </View>
397
+ <Dropdown ink={ink} open={showSort} onToggle={() => setShowSort((v) => !v)} label={sort.label} icon={<ArrowDownUp size={12} color={ink.muted}/>}>
398
+ {SORT_OPTIONS.map((o, idx) => (<Pressable key={o.value} onPress={() => {
399
+ setSortIdx(idx);
400
+ setShowSort(false);
401
+ }} className={`px-3 py-1.5 active:opacity-70 ${idx === sortIdx ? "bg-secondary" : ""}`}>
402
+ <Text className={`text-xs ${idx === sortIdx ? "font-semibold" : ""}`}>
403
+ {o.label}
404
+ </Text>
405
+ </Pressable>))}
406
+ </Dropdown>
407
+ </View>
408
+ {all.map((i) => (<VaultItemRow key={i.id} item={i} ink={ink} onPress={() => onOpen(i.id)} highlight={highlightId === i.id}/>))}
409
+ </View>)}
410
+ </ScrollView>
411
+ </View>);
412
+ }
413
+ // ── item detail (app/(tabs)/vault/[id]/index.tsx) ────────────────────────────
414
+ export function FieldRow({ label, value, ink, copied, copy, secret, totp, }) {
415
+ const [show, setShow] = useState(false);
416
+ const isCopied = copied === label;
417
+ const display = totp ? totp.code : secret && !show ? "••••••••••••" : value;
418
+ return (<View className="mb-4">
419
+ <Text className="mb-1 text-xs text-muted-foreground">{label}</Text>
420
+ <View className="flex-row items-center justify-between rounded-lg bg-secondary p-3">
421
+ <RNText className="flex-1 font-mono text-sm text-foreground" numberOfLines={1} style={{ color: ink.fg }}>
422
+ {display}
423
+ </RNText>
424
+ <View className="flex-row items-center gap-2">
425
+ {secret && (<Pressable onPress={() => setShow((v) => !v)} className="active:opacity-60">
426
+ {show ? <EyeOff size={18} color={ink.muted}/> : <Eye size={18} color={ink.muted}/>}
427
+ </Pressable>)}
428
+ {totp && (<View className="mr-1">
429
+ <RingCountdown remaining={totp.remaining} color={ink.fg}/>
430
+ </View>)}
431
+ <Pressable nativeID={totp ? "fcopy-totp" : undefined} onPress={() => copy(label, totp ? totp.code : value)} className="active:opacity-60">
432
+ {isCopied ? <Check size={18} color="#22c55e"/> : <Copy size={18} color={ink.muted}/>}
433
+ </Pressable>
434
+ </View>
435
+ </View>
436
+ </View>);
437
+ }
438
+ export function ItemDetail({ item, ink, onBack, onDelete, forceCopiedLabel, scrollRef, }) {
439
+ // Sample data always has one; the guard is for the type checker, and
440
+ // rendering nothing beats crashing if that ever stops being true.
441
+ const acc = item.accounts[0];
442
+ const accSecret = acc?.totp_secret;
443
+ const { copied, copy } = useCopy();
444
+ const totp = useTotp(accSecret);
445
+ const [editing, setEditing] = useState(false);
446
+ const [confirm, setConfirm] = useState(false);
447
+ // In autoplay the copied highlight is driven externally; otherwise the
448
+ // real per-field copy state.
449
+ const effCopied = forceCopiedLabel ?? copied;
450
+ if (editing) {
451
+ return <ExtItemForm ink={ink} item={item} onCancel={() => setEditing(false)}/>;
452
+ }
453
+ // After the hooks, so the hook order never changes. Sample items always carry
454
+ // an account; this is the type checker's guard, and rendering nothing beats
455
+ // crashing if that ever stops being true.
456
+ if (!acc) {
457
+ return null;
458
+ }
459
+ function launch() {
460
+ void Linking.openURL(item.url).catch(() => undefined);
461
+ }
462
+ return (<ScrollView ref={scrollRef} className="flex-1" contentContainerStyle={{ padding: 16 }}>
463
+ {/* top bar */}
464
+ <View className="mb-4 flex-row items-center justify-between">
465
+ <Pressable onPress={onBack} className="active:opacity-70">
466
+ <ArrowLeft size={22} color={ink.fg}/>
467
+ </Pressable>
468
+ <View className="flex-row gap-4">
469
+ <Pressable onPress={launch} hitSlop={8} className="active:opacity-60">
470
+ <ExternalLink size={20} color={ink.fg}/>
471
+ </Pressable>
472
+ <Pressable onPress={() => setEditing(true)} hitSlop={8} className="active:opacity-60">
473
+ <SquarePen size={20} color={ink.fg}/>
474
+ </Pressable>
475
+ <Pressable onPress={() => setConfirm(true)} hitSlop={8} className="active:opacity-60">
476
+ <Trash2 size={20} color={ink.danger}/>
477
+ </Pressable>
478
+ </View>
479
+ </View>
480
+
481
+ {confirm && (<ExtModal title="Delete item" ink={ink}>
482
+ <Text className="text-sm text-muted-foreground">
483
+ Delete &quot;{item.name}&quot;? This can&apos;t be undone.
484
+ </Text>
485
+ <View className="flex-row gap-2">
486
+ <View className="flex-1">
487
+ <Button variant="outline" onPress={() => setConfirm(false)}>
488
+ Cancel
489
+ </Button>
490
+ </View>
491
+ <View className="flex-1">
492
+ <Button variant="destructive" onPress={() => {
493
+ setConfirm(false);
494
+ (onDelete ?? onBack)();
495
+ }}>
496
+ Delete
497
+ </Button>
498
+ </View>
499
+ </View>
500
+ </ExtModal>)}
501
+
502
+ {/* header */}
503
+ <View className="mb-6 flex-row items-center gap-3">
504
+ <ItemIcon url={item.url} size={56} radius={14}/>
505
+ <View className="flex-1">
506
+ <View className="flex-row items-center gap-2">
507
+ <Text className="text-xl font-bold">{item.name}</Text>
508
+ {item.favorite && <Star size={18} color={ink.star} fill={ink.star}/>}
509
+ </View>
510
+ <MutedText className="text-sm" numberOfLines={1}>
511
+ {host(item.url)}
512
+ </MutedText>
513
+ </View>
514
+ </View>
515
+
516
+ {/* fields */}
517
+ <View className="rounded-lg border border-border bg-card p-4">
518
+ <FieldRow label="Username / Email" value={acc.username} ink={ink} copied={effCopied} copy={copy}/>
519
+ <FieldRow label="Password" value={acc.password} ink={ink} copied={effCopied} copy={copy} secret/>
520
+ <FieldRow label="URL" value={item.url} ink={ink} copied={effCopied} copy={copy}/>
521
+ {acc.totp_secret && (<FieldRow label="TOTP Code" value={acc.totp_secret} ink={ink} copied={effCopied} copy={copy} totp={totp}/>)}
522
+ </View>
523
+
524
+ {/* notes */}
525
+ <View className="mt-4 rounded-lg border border-border bg-card p-4">
526
+ <Text className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Notes</Text>
527
+ {acc.notes ? (<Text className="text-sm">{acc.notes}</Text>) : (<Text className="text-sm italic text-muted-foreground">
528
+ No notes — tap Edit to add some.
529
+ </Text>)}
530
+ </View>
531
+
532
+ {/* details */}
533
+ <View className="mt-4 rounded-lg border border-border bg-card p-4">
534
+ <Text className="mb-2 text-xs font-semibold uppercase text-muted-foreground">Details</Text>
535
+ <MutedText className="mb-1 text-xs">Created: {item.created}</MutedText>
536
+ <MutedText className="text-xs">Updated: {item.updated}</MutedText>
537
+ <View className="mt-3">
538
+ <MutedText className="mb-1.5 text-xs">Tags</MutedText>
539
+ <View className="flex-row flex-wrap gap-1.5">
540
+ {item.tags.map((t) => {
541
+ const Icon = t.icon === "circle" ? CircleIcon : t.icon === "star" ? Star : TagIcon;
542
+ return (<View key={t.name} className="flex-row items-center gap-1 rounded-md border px-2 py-1" style={{ borderColor: `${t.color}55` }}>
543
+ <Icon size={12} color={t.color}/>
544
+ <Text className="text-xs">{t.name}</Text>
545
+ </View>);
546
+ })}
547
+ </View>
548
+ </View>
549
+ </View>
550
+ </ScrollView>);
551
+ }
552
+ // ── generator (entropy-ui PasswordGenerator, reproduced) ──────────────────────
553
+ function ExtBottomNav({ active, onChange, ink, }) {
554
+ const tabs = [
555
+ ["vault", "Login Items", Lock],
556
+ ["tags", "Tags", Folder],
557
+ ["send", "Send", Send],
558
+ ["secrets", "Secrets", KeyRound],
559
+ ];
560
+ return (<View className="flex-row border-t border-border">
561
+ {tabs.map(([id, label, Icon]) => {
562
+ const on = id === active;
563
+ return (<Pressable key={id} onPress={() => onChange(id)} className="flex-1 items-center justify-center gap-0.5 py-2 active:opacity-70">
564
+ <Icon size={18} color={on ? ink.fg : ink.muted}/>
565
+ <RNText style={{
566
+ fontSize: 10,
567
+ fontWeight: on ? "600" : "400",
568
+ color: on ? ink.fg : ink.muted,
569
+ }}>
570
+ {label}
571
+ </RNText>
572
+ </Pressable>);
573
+ })}
574
+ </View>);
575
+ }
576
+ function ExtComingSoon({ icon, desc, ink, }) {
577
+ return (<View className="flex-1 items-center justify-center px-8" style={{ gap: 10 }}>
578
+ {icon}
579
+ <MutedText className="text-center text-sm" style={{ maxWidth: 260 }}>
580
+ {desc}
581
+ </MutedText>
582
+ <RNText style={{ fontSize: 11, fontStyle: "italic", color: ink.muted }}>
583
+ Coming soon
584
+ </RNText>
585
+ </View>);
586
+ }
587
+ /**
588
+ * The mark in the popup header.
589
+ *
590
+ * Supplied by the caller rather than loaded from a path. The original pointed
591
+ * at `/products/vault_light.png`, which only resolves on the marketing site —
592
+ * a shared package cannot reach into one consumer's public directory. Falls
593
+ * back to the wordmark so the header is never empty.
594
+ */
595
+ export function VaultMark({ logo }) {
596
+ if (logo) {
597
+ return <>{logo}</>;
598
+ }
599
+ return <Text className="text-base font-bold">Vault</Text>;
600
+ }
601
+ function ExtHeader({ tab, ink, onAdd, onAddFolder, onSettings, logo, }) {
602
+ return (<View className="flex-row items-center gap-3 border-b border-border px-4 py-3">
603
+ <VaultMark logo={logo}/>
604
+ <View className="flex-1"/>
605
+ {tab === "tags" && (<Pressable onPress={onAddFolder} className="active:opacity-70">
606
+ <FolderPlus size={18} color={ink.fg} strokeWidth={1.75}/>
607
+ </Pressable>)}
608
+ <Pressable onPress={onAdd} className="active:opacity-70">
609
+ <Plus size={20} color={ink.fg} strokeWidth={1.75}/>
610
+ </Pressable>
611
+ <Pressable onPress={onSettings} className="active:opacity-70">
612
+ <SettingsIcon size={20} color={ink.fg} strokeWidth={1.75}/>
613
+ </Pressable>
614
+ </View>);
615
+ }
616
+ const SEED_FOLDERS = [
617
+ { id: "f1", name: "Personal" },
618
+ { id: "f2", name: "Finance" },
619
+ { id: "f3", name: "Gaming" },
620
+ ];
621
+ const SEED_TAGS = [
622
+ { id: "t1", ...T.social, folderId: "f1" },
623
+ { id: "t2", ...T.streaming, folderId: "f1" },
624
+ { id: "t3", ...T.finance, folderId: "f2" },
625
+ { id: "t4", ...T.gaming, folderId: "f3" },
626
+ { id: "t5", ...T.shopping, folderId: null },
627
+ ];
628
+ function TagChip({ tag }) {
629
+ const Icon = tag.icon === "circle" ? CircleIcon : tag.icon === "star" ? Star : TagIcon;
630
+ return (<View className="flex-row items-center gap-1 self-start rounded-md border px-2 py-1" style={{ borderColor: `${tag.color}55` }}>
631
+ <Icon size={11} color={tag.color}/>
632
+ <Text className="text-xs">{tag.name}</Text>
633
+ </View>);
634
+ }
635
+ function ExtDragHandle({ ink }) {
636
+ // Affordance only. The website's version wires this to @dnd-kit, which is
637
+ // DOM-only and cannot ship in a component React Native also renders. The grip
638
+ // stays because removing it would change the extension's actual layout, which
639
+ // is the thing this demo exists to reproduce.
640
+ return (<View>
641
+ <GripVertical size={14} color={ink.muted} strokeWidth={1.75}/>
642
+ </View>);
643
+ }
644
+ function ExtTagRow({ tag, ink, onEdit, onDelete, }) {
645
+ return (<View className="flex-row items-center gap-2 rounded-md px-1.5 py-1.5">
646
+ <ExtDragHandle ink={ink}/>
647
+ <View className="flex-1">
648
+ <TagChip tag={tag}/>
649
+ </View>
650
+ <Pressable onPress={onEdit} hitSlop={6} className="active:opacity-60">
651
+ <Pencil size={13} color={ink.muted} strokeWidth={1.75}/>
652
+ </Pressable>
653
+ <Pressable onPress={onDelete} hitSlop={6} className="active:opacity-60">
654
+ <Trash2 size={13} color={ink.danger} strokeWidth={1.75}/>
655
+ </Pressable>
656
+ </View>);
657
+ }
658
+ function ExtFolderBlock({ folder, tags, collapsed, ink, onToggle, onAdd, onEdit, onDelete, onEditTag, onDeleteTag, }) {
659
+ return (<View className="overflow-hidden rounded-lg border bg-card" style={{ borderColor: ink.border }}>
660
+ <View className="flex-row items-center gap-2 px-2.5 py-2.5">
661
+ <ExtDragHandle ink={ink}/>
662
+ <Pressable onPress={onToggle} hitSlop={4} className="active:opacity-60">
663
+ {collapsed ? (<ChevronRight size={14} color={ink.muted} strokeWidth={1.75}/>) : (<ChevronDown size={14} color={ink.muted} strokeWidth={1.75}/>)}
664
+ </Pressable>
665
+ <Folder size={14} color={ink.fg} strokeWidth={1.75}/>
666
+ <Pressable onPress={onToggle} className="flex-1">
667
+ <Text className="text-[13px] font-semibold">{folder.name}</Text>
668
+ </Pressable>
669
+ <Text className="text-[11px] text-muted-foreground">{tags.length}</Text>
670
+ <Pressable onPress={onAdd} hitSlop={6} className="active:opacity-60">
671
+ <Plus size={14} color={ink.muted} strokeWidth={1.75}/>
672
+ </Pressable>
673
+ <Pressable onPress={onEdit} hitSlop={6} className="active:opacity-60">
674
+ <Pencil size={14} color={ink.muted} strokeWidth={1.75}/>
675
+ </Pressable>
676
+ <Pressable onPress={onDelete} hitSlop={6} className="active:opacity-60">
677
+ <Trash2 size={14} color={ink.danger} strokeWidth={1.75}/>
678
+ </Pressable>
679
+ </View>
680
+ {!collapsed && (<View className="px-2.5 pb-2.5" style={{ gap: 6 }}>
681
+ {tags.length === 0 ? (<Text className="py-1 text-[11px] italic text-muted-foreground">
682
+ Drop tags here, or use + to add
683
+ </Text>) : (tags.map((t) => (<ExtTagRow key={t.id} tag={t} ink={ink} onEdit={() => onEditTag(t)} onDelete={() => onDeleteTag(t.id)}/>)))}
684
+ </View>)}
685
+ </View>);
686
+ }
687
+ function ExtUngrouped({ tags, ink, onEditTag, onDeleteTag, }) {
688
+ return (<View className="mt-1 rounded-lg border bg-card p-2.5" style={{ borderColor: ink.border, gap: 6 }}>
689
+ <Text className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
690
+ Ungrouped
691
+ </Text>
692
+ {tags.length === 0 ? (<Text className="py-1 text-[11px] italic text-muted-foreground">No tags</Text>) : (tags.map((t) => (<ExtTagRow key={t.id} tag={t} ink={ink} onEdit={() => onEditTag(t)} onDelete={() => onDeleteTag(t.id)}/>)))}
693
+ </View>);
694
+ }
695
+ function ExtTagsTab({ ink }) {
696
+ const [folders, setFolders] = useState(SEED_FOLDERS);
697
+ const [tags, setTags] = useState(SEED_TAGS);
698
+ const [collapsed, setCollapsed] = useState(new Set());
699
+ const [modal, setModal] = useState(null);
700
+ function toggle(id) {
701
+ setCollapsed((s) => {
702
+ const n = new Set(s);
703
+ if (n.has(id)) {
704
+ n.delete(id);
705
+ }
706
+ else {
707
+ n.add(id);
708
+ }
709
+ return n;
710
+ });
711
+ }
712
+ function deleteTag(id) {
713
+ setTags((s) => s.filter((t) => t.id !== id));
714
+ }
715
+ function deleteFolder(id) {
716
+ setFolders((s) => s.filter((f) => f.id !== id));
717
+ setTags((s) => s.map((t) => (t.folderId === id ? { ...t, folderId: null } : t)));
718
+ }
719
+ const ungrouped = tags.filter((t) => !t.folderId);
720
+ return (<View className="flex-1">
721
+ <ScrollView className="flex-1" contentContainerStyle={{ padding: 12, gap: 8 }}>
722
+ {folders.map((f) => (<ExtFolderBlock key={f.id} folder={f} tags={tags.filter((t) => 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}/>))}
723
+ <ExtUngrouped tags={ungrouped} ink={ink} onEditTag={(t) => setModal({ kind: "tag", tag: t })} onDeleteTag={deleteTag}/>
724
+ </ScrollView>
725
+ {modal?.kind === "tag" && (<ExtTagModal ink={ink} tag={modal.tag} onClose={() => setModal(null)}/>)}
726
+ {modal?.kind === "folder" && (<ExtFolderModal ink={ink} folder={modal.folder} onClose={() => setModal(null)}/>)}
727
+ </View>);
728
+ }
729
+ function ExtSettingsRow({ icon, label, ink, onPress, }) {
730
+ return (<Pressable onPress={onPress} className="flex-row items-center gap-3 rounded-lg border border-border px-3 py-3 active:bg-secondary">
731
+ {icon}
732
+ <Text className="flex-1 text-sm">{label}</Text>
733
+ <ChevronRight size={16} color={ink.muted}/>
734
+ </Pressable>);
735
+ }
736
+ // Settings flow (clients/extension SettingsScreen + PasskeyListScreen +
737
+ // ServerConfigScreen). Owns its own little sub-router so the gear is a full
738
+ // port: menu → Passkeys → Add passkey, and menu → Server URL.
739
+ function ExtSettings({ ink, onBack }) {
740
+ const [sub, setSub] = useState("menu");
741
+ if (sub === "passkeys") {
742
+ return <ExtPasskeys ink={ink} onBack={() => setSub("menu")} onAdd={() => setSub("enroll")}/>;
743
+ }
744
+ if (sub === "enroll") {
745
+ return <ExtEnroll ink={ink} onBack={() => setSub("passkeys")}/>;
746
+ }
747
+ if (sub === "server") {
748
+ return <ExtServerConfig ink={ink} onBack={() => setSub("menu")}/>;
749
+ }
750
+ return (<View className="flex-1 p-5" style={{ gap: 12 }}>
751
+ <View className="flex-row items-center justify-between">
752
+ <VaultMark />
753
+ <Pressable onPress={onBack} hitSlop={8} className="active:opacity-60">
754
+ <Text className="text-[13px] font-medium text-muted-foreground">Done</Text>
755
+ </Pressable>
756
+ </View>
757
+ <Text className="mt-2 text-lg font-semibold">Settings</Text>
758
+ <ScrollView className="flex-1" contentContainerStyle={{ gap: 8, paddingBottom: 16 }}>
759
+ <ExtSettingsRow ink={ink} icon={<KeyRound size={18} color={ink.fg} strokeWidth={1.75}/>} label="Passkeys" onPress={() => setSub("passkeys")}/>
760
+ <ExtSettingsRow ink={ink} icon={<Server size={18} color={ink.fg} strokeWidth={1.75}/>} label="Server URL" onPress={() => setSub("server")}/>
761
+ <View style={{ height: 8 }}/>
762
+ <Button variant="destructive" onPress={() => { }}>
763
+ <View className="flex-row items-center justify-center gap-2">
764
+ <LogOut size={16} color="#ffffff" strokeWidth={2}/>
765
+ <Text className="text-sm font-medium" style={{ color: "#ffffff" }}>
766
+ Sign out
767
+ </Text>
768
+ </View>
769
+ </Button>
770
+ <Text className="mt-1 text-xs text-muted-foreground">
771
+ Signing out clears your session token. Your encrypted vault stays on the server.
772
+ </Text>
773
+ </ScrollView>
774
+ </View>);
775
+ }
776
+ // Passkey management (PasskeyListScreen) — popup directs rename/delete to the
777
+ // main app behind the MFA gate; "Add passkey" stays one click away.
778
+ function ExtPasskeys({ ink, onBack, onAdd, }) {
779
+ return (<View className="flex-1 p-5" style={{ gap: 12 }}>
780
+ <View className="flex-row items-center justify-between">
781
+ <View className="flex-row items-center gap-2">
782
+ <Pressable onPress={onBack} hitSlop={8} className="active:opacity-60">
783
+ <ChevronLeft size={20} color={ink.fg} strokeWidth={1.75}/>
784
+ </Pressable>
785
+ <VaultMark />
786
+ </View>
787
+ <Text className="text-base font-semibold">Passkeys</Text>
788
+ </View>
789
+ <ScrollView className="flex-1" contentContainerStyle={{ gap: 16, paddingBottom: 16 }}>
790
+ <View style={{ gap: 8 }}>
791
+ <Button onPress={onAdd}>
792
+ <View className="flex-row items-center justify-center gap-2">
793
+ <Plus size={16} color={ink.bg} strokeWidth={2}/>
794
+ <Text className="text-sm font-medium" style={{ color: ink.bg }}>
795
+ Add passkey
796
+ </Text>
797
+ </View>
798
+ </Button>
799
+ <Text className="text-xs text-muted-foreground">
800
+ Register a new passkey for this account using your browser&apos;s built-in WebAuthn
801
+ dialog.
802
+ </Text>
803
+ </View>
804
+ <View className="rounded-lg border border-border p-3" style={{ gap: 8 }}>
805
+ <View className="flex-row items-center gap-2">
806
+ <ShieldAlert size={16} color={ink.fg} strokeWidth={1.75}/>
807
+ <Text className="flex-1 text-sm font-medium">
808
+ Rename or remove passkeys in the main app
809
+ </Text>
810
+ </View>
811
+ <Text className="text-xs leading-4 text-muted-foreground">
812
+ Renaming and deleting passkeys requires multi-factor verification. The extension popup
813
+ doesn&apos;t surface a TOTP prompt today, so those actions live in the main Vault app
814
+ (web or mobile).
815
+ </Text>
816
+ <View className="mt-1">
817
+ <Button variant="outline" onPress={() => { }}>
818
+ <View className="flex-row items-center justify-center gap-2">
819
+ <ExternalLink size={14} color={ink.fg} strokeWidth={1.75}/>
820
+ <Text className="text-[13px] font-medium">Open the main app</Text>
821
+ </View>
822
+ </Button>
823
+ <Text className="mt-1 text-[11px] text-muted-foreground" numberOfLines={1}>
824
+ vault.example.com
825
+ </Text>
826
+ </View>
827
+ </View>
828
+ </ScrollView>
829
+ </View>);
830
+ }
831
+ function ExtEnroll({ ink, onBack }) {
832
+ return (<View className="flex-1">
833
+ <View className="flex-row items-center gap-2 px-4 py-3">
834
+ <Pressable onPress={onBack} hitSlop={8} className="active:opacity-60">
835
+ <ChevronLeft size={20} color={ink.fg} strokeWidth={1.75}/>
836
+ </Pressable>
837
+ <VaultMark />
838
+ </View>
839
+ <ScrollView className="flex-1" contentContainerStyle={{ padding: 16 }}>
840
+ <PasskeyEnroll ink={ink}/>
841
+ </ScrollView>
842
+ </View>);
843
+ }
844
+ async function probeServer(rawUrl) {
845
+ const trimmed = rawUrl.trim();
846
+ let parsed;
847
+ try {
848
+ parsed = new URL(trimmed);
849
+ }
850
+ catch {
851
+ return { ok: false, message: "Invalid URL format" };
852
+ }
853
+ const target = `${trimmed.replace(/\/+$/, "")}/health`;
854
+ const ctrl = new AbortController();
855
+ const timer = setTimeout(() => ctrl.abort(), 8000);
856
+ try {
857
+ const res = await fetch(target, { method: "GET", credentials: "omit", signal: ctrl.signal });
858
+ if (!res.ok) {
859
+ return { ok: false, message: `Server responded with HTTP ${res.status}` };
860
+ }
861
+ let body = {};
862
+ try {
863
+ body = (await res.json());
864
+ }
865
+ catch {
866
+ return { ok: false, message: "Unexpected response body" };
867
+ }
868
+ if (body.status !== "healthy") {
869
+ return { ok: false, message: `Server reported status: ${body.status ?? "unknown"}` };
870
+ }
871
+ return {
872
+ ok: true,
873
+ message: body.version ? `Server reachable (version ${body.version})` : "Server reachable",
874
+ };
875
+ }
876
+ catch (e) {
877
+ const reason = e instanceof Error ? e.message : "network error";
878
+ if (parsed.protocol === "https:" && reason.toLowerCase().includes("certificate")) {
879
+ return { ok: false, message: "TLS certificate validation failed" };
880
+ }
881
+ if (reason.toLowerCase().includes("abort") || reason.toLowerCase().includes("timeout")) {
882
+ return { ok: false, message: "Request timed out" };
883
+ }
884
+ return { ok: false, message: `Could not reach server (${reason})` };
885
+ }
886
+ finally {
887
+ clearTimeout(timer);
888
+ }
889
+ }
890
+ function ExtServerConfig({ ink, onBack }) {
891
+ const DEFAULT_URL = "https://vault.example.com/api";
892
+ const [url, setUrl] = useState(DEFAULT_URL);
893
+ const [testing, setTesting] = useState(false);
894
+ const [result, setResult] = useState(null);
895
+ async function test() {
896
+ setTesting(true);
897
+ setResult(null);
898
+ const r = await probeServer(url);
899
+ setResult(r);
900
+ setTesting(false);
901
+ }
902
+ return (<ScrollView className="flex-1" contentContainerStyle={{ padding: 24, gap: 16 }}>
903
+ <View className="items-start" style={{ marginBottom: 8 }}>
904
+ <VaultMark />
905
+ </View>
906
+ <View style={{ gap: 4, marginBottom: 4 }}>
907
+ <Text className="text-[22px] font-semibold">Server URL</Text>
908
+ <Text className="text-[13px] text-muted-foreground">
909
+ Point this extension at your self-hosted Vault server. HTTPS required (loopback may use
910
+ HTTP).
911
+ </Text>
912
+ </View>
913
+ <ExtField label="URL" placeholder="https://vault.example.com/api" value={url} onChange={(v) => {
914
+ setUrl(v);
915
+ setResult(null);
916
+ }} ink={ink}/>
917
+ {result && (<View className="rounded-lg border p-2.5" style={{
918
+ borderColor: result.ok ? "rgb(34,197,94)" : ink.danger,
919
+ backgroundColor: result.ok ? "rgba(34,197,94,0.08)" : "rgba(239,68,68,0.08)",
920
+ }}>
921
+ <Text className="text-[13px] font-medium" style={{ color: result.ok ? "rgb(34,197,94)" : ink.danger }}>
922
+ {result.ok ? "✓ " : "✗ "}
923
+ {result.message}
924
+ </Text>
925
+ </View>)}
926
+ <View className="flex-row gap-2">
927
+ <View className="flex-1">
928
+ <Button variant="outline" onPress={test} isLoading={testing}>
929
+ {testing ? "" : "Test"}
930
+ </Button>
931
+ </View>
932
+ <View className="flex-1">
933
+ <Button onPress={onBack}>Save</Button>
934
+ </View>
935
+ </View>
936
+ <Button variant="ghost" onPress={() => {
937
+ setUrl(DEFAULT_URL);
938
+ setResult(null);
939
+ }}>
940
+ Reset to default
941
+ </Button>
942
+ <Pressable onPress={onBack} className="items-center active:opacity-60" style={{ marginTop: 4 }}>
943
+ <Text className="text-[13px] text-muted-foreground">Back</Text>
944
+ </Pressable>
945
+ </ScrollView>);
946
+ }
947
+ function ExtField({ label, placeholder, value, onChange, ink, secret, }) {
948
+ const [show, setShow] = useState(false);
949
+ return (<View style={{ gap: 6 }}>
950
+ <Text className="text-xs text-muted-foreground">{label}</Text>
951
+ <View className="flex-row items-center rounded-lg border border-border px-3" style={{ height: 40 }}>
952
+ <TextInput value={value} onChangeText={onChange} placeholder={placeholder} placeholderTextColor={ink.muted} secureTextEntry={secret && !show} className="flex-1 text-sm text-foreground" style={{ outlineStyle: "none" }}/>
953
+ {secret && (<Pressable onPress={() => setShow((s) => !s)} className="active:opacity-60">
954
+ {show ? <EyeOff size={16} color={ink.muted}/> : <Eye size={16} color={ink.muted}/>}
955
+ </Pressable>)}
956
+ </View>
957
+ </View>);
958
+ }
959
+ // New / edit login form (clients/extension VaultItemFormScreen). With `item`
960
+ // the fields prefill and the screen acts as the editor.
961
+ function ExtItemForm({ ink, onCancel, item, }) {
962
+ const acc = item?.accounts[0];
963
+ const [fav, setFav] = useState(item?.favorite ?? false);
964
+ const [picked, setPicked] = useState(item?.tags.map((t) => t.name) ?? []);
965
+ const [f, setF] = useState({
966
+ name: item?.name ?? "",
967
+ url: item?.url ?? "",
968
+ label: "",
969
+ username: acc?.username ?? "",
970
+ password: acc?.password ?? "",
971
+ totp: acc?.totp_secret ?? "",
972
+ notes: acc?.notes ?? "",
973
+ });
974
+ const set = (k) => (v) => setF((s) => ({ ...s, [k]: v }));
975
+ const allTags = Object.values(T);
976
+ return (<View className="flex-1">
977
+ <View className="flex-row items-center justify-between border-b border-border px-4 py-3">
978
+ <Pressable onPress={onCancel} className="active:opacity-70">
979
+ <ArrowLeft size={20} color={ink.fg}/>
980
+ </Pressable>
981
+ <Text className="text-base font-bold">{item ? "Edit item" : "New item"}</Text>
982
+ <Pressable onPress={() => setFav((v) => !v)} className="active:opacity-70">
983
+ <Star size={20} color={fav ? ink.star : ink.muted} fill={fav ? ink.star : "transparent"}/>
984
+ </Pressable>
985
+ </View>
986
+ <ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 14 }}>
987
+ <ExtField label="Name" placeholder="e.g. Google" value={f.name} onChange={set("name")} ink={ink}/>
988
+ <ExtField label="URL" placeholder="https://example.com" value={f.url} onChange={set("url")} ink={ink}/>
989
+ <View style={{ gap: 6 }}>
990
+ <Text className="text-xs text-muted-foreground">Tags</Text>
991
+ <View className="flex-row flex-wrap gap-1.5">
992
+ {allTags.map((t) => {
993
+ const on = picked.includes(t.name);
994
+ const Icon = t.icon === "circle" ? CircleIcon : t.icon === "star" ? Star : TagIcon;
995
+ return (<Pressable key={t.name} onPress={() => 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={{
996
+ borderColor: `${t.color}${on ? "" : "55"}`,
997
+ backgroundColor: on ? `${t.color}22` : "transparent",
998
+ }}>
999
+ <Icon size={11} color={t.color}/>
1000
+ <Text className="text-xs">{t.name}</Text>
1001
+ </Pressable>);
1002
+ })}
1003
+ </View>
1004
+ </View>
1005
+ <View className="rounded-lg border border-border bg-card p-3" style={{ gap: 12 }}>
1006
+ <ExtField label="Label" placeholder="e.g. Personal, Work" value={f.label} onChange={set("label")} ink={ink}/>
1007
+ <ExtField label="Username" placeholder="you@example.com" value={f.username} onChange={set("username")} ink={ink}/>
1008
+ <ExtField label="Password" placeholder="••••••••" value={f.password} onChange={set("password")} ink={ink} secret/>
1009
+ <ExtField label="TOTP secret (base32)" placeholder="JBSWY3DPEHPK3PXP" value={f.totp} onChange={set("totp")} ink={ink}/>
1010
+ <ExtField label="Notes" placeholder="Optional" value={f.notes} onChange={set("notes")} ink={ink}/>
1011
+ </View>
1012
+ <Pressable onPress={() => { }} className="flex-row items-center justify-center gap-2 rounded-lg border border-border py-2.5 active:opacity-80">
1013
+ <Plus size={16} color={ink.fg}/>
1014
+ <Text className="text-sm font-medium">Add account</Text>
1015
+ </Pressable>
1016
+ <View className="flex-row gap-2 pt-1">
1017
+ <View className="flex-1">
1018
+ <Button variant="outline" onPress={onCancel}>
1019
+ Cancel
1020
+ </Button>
1021
+ </View>
1022
+ <View className="flex-1">
1023
+ <Button onPress={onCancel}>{item ? "Save changes" : "Create"}</Button>
1024
+ </View>
1025
+ </View>
1026
+ </ScrollView>
1027
+ </View>);
1028
+ }
1029
+ function ExtModal({ title, ink, children, }) {
1030
+ void ink;
1031
+ return (<View className="absolute inset-0 z-50 items-center justify-center p-5" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
1032
+ <View className="w-full rounded-xl border border-border bg-card p-4" style={{ maxWidth: 320, gap: 14 }}>
1033
+ <Text className="text-sm font-bold">{title}</Text>
1034
+ {children}
1035
+ </View>
1036
+ </View>);
1037
+ }
1038
+ const TAG_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#a855f7", "#06b6d4", "#ef4444"];
1039
+ function ExtTagModal({ ink, onClose, tag, }) {
1040
+ const [name, setName] = useState(tag?.name ?? "");
1041
+ const [color, setColor] = useState(tag?.color ?? TAG_COLORS[0]);
1042
+ return (<ExtModal title={tag ? "Edit tag" : "New tag"} ink={ink}>
1043
+ <ExtField label="Name" placeholder="Personal" value={name} onChange={setName} ink={ink}/>
1044
+ <View style={{ gap: 6 }}>
1045
+ <Text className="text-xs text-muted-foreground">Color</Text>
1046
+ <View className="flex-row gap-2">
1047
+ {TAG_COLORS.map((c) => (<Pressable key={c} onPress={() => setColor(c)} style={{
1048
+ width: 28,
1049
+ height: 28,
1050
+ borderRadius: 999,
1051
+ backgroundColor: c,
1052
+ borderWidth: color === c ? 2 : 0,
1053
+ borderColor: ink.fg,
1054
+ }}/>))}
1055
+ </View>
1056
+ </View>
1057
+ <View className="flex-row gap-2">
1058
+ <View className="flex-1">
1059
+ <Button variant="outline" onPress={onClose}>
1060
+ Cancel
1061
+ </Button>
1062
+ </View>
1063
+ <View className="flex-1">
1064
+ <Button onPress={onClose}>{tag ? "Save" : "Create"}</Button>
1065
+ </View>
1066
+ </View>
1067
+ </ExtModal>);
1068
+ }
1069
+ function ExtFolderModal({ ink, onClose, folder, }) {
1070
+ const [name, setName] = useState(folder?.name ?? "");
1071
+ return (<ExtModal title={folder ? "Edit folder" : "New folder"} ink={ink}>
1072
+ <ExtField label="Name" placeholder="Work" value={name} onChange={setName} ink={ink}/>
1073
+ <View className="flex-row gap-2">
1074
+ <View className="flex-1">
1075
+ <Button variant="outline" onPress={onClose}>
1076
+ Cancel
1077
+ </Button>
1078
+ </View>
1079
+ <View className="flex-1">
1080
+ <Button onPress={onClose}>{folder ? "Save" : "Create"}</Button>
1081
+ </View>
1082
+ </View>
1083
+ </ExtModal>);
1084
+ }
1085
+ // External controller — lets a parent scene drive the popup (route, the
1086
+ // highlighted row, the "copied" field, and the list/detail scroll positions)
1087
+ // instead of the popup's own internal autoplay. Used by TotpScene so a single
1088
+ // scene-level cursor can hand the code off to a browser window.
1089
+ function PasskeyEnroll({ ink }) {
1090
+ const [label, setLabel] = useState("My iPhone");
1091
+ return (<Card className="w-full max-w-[480px] self-center" style={vaultVars(ink.fg === "#ffffff")}>
1092
+ <CardHeader className="items-center">
1093
+ <View className="mb-3 h-16 w-16 items-center justify-center rounded-full bg-secondary">
1094
+ <KeyRound size={30} color={ink.fg}/>
1095
+ </View>
1096
+ <CardTitle className="text-center">Add a passkey</CardTitle>
1097
+ <CardDescription className="text-center">
1098
+ Passkeys are a faster, phishing-resistant way to sign in. Use Face ID, Touch ID, or a
1099
+ hardware key.
1100
+ </CardDescription>
1101
+ </CardHeader>
1102
+ <CardContent className="gap-4">
1103
+ <View className="flex-row items-start gap-2 rounded-lg bg-secondary p-3">
1104
+ <View className="mt-0.5">
1105
+ <Fingerprint size={16} color={ink.muted}/>
1106
+ </View>
1107
+ <MutedText className="flex-1 text-xs leading-4">
1108
+ Your passkey stays on this device (or in iCloud Keychain / Google Password Manager).
1109
+ Vault never sees the private key.
1110
+ </MutedText>
1111
+ </View>
1112
+ <View className="gap-1.5">
1113
+ <Text className="text-xs text-muted-foreground">Passkey name</Text>
1114
+ <View className="rounded-lg border border-border px-3 py-2.5">
1115
+ <TextInput value={label} onChangeText={setLabel} placeholder="My iPhone" placeholderTextColor={ink.muted} className="text-sm text-foreground" style={{ outlineStyle: "none" }}/>
1116
+ </View>
1117
+ <MutedText className="text-xs">
1118
+ Used to identify this passkey in your settings list.
1119
+ </MutedText>
1120
+ </View>
1121
+ </CardContent>
1122
+ <CardFooter className="flex-col gap-2 border-t-0">
1123
+ <Button className="w-full">Create passkey</Button>
1124
+ </CardFooter>
1125
+ </Card>);
1126
+ }
1127
+ /**
1128
+ * The browser-extension popup.
1129
+ *
1130
+ * Interactive by default. Pass `external` to have a parent scene drive it.
1131
+ */
1132
+ export function ExtensionPopup({ logo, external, height = EXTENSION_POPUP_HEIGHT, } = {}) {
1133
+ const ink = useInk();
1134
+ const dark = ink.fg === "#ffffff";
1135
+ const [tab, setTab] = useState("vault");
1136
+ const [route, setRoute] = useState({ r: "main" });
1137
+ const [modal, setModal] = useState(null);
1138
+ const [items, setItems] = useState(ITEMS);
1139
+ const driven = !!external;
1140
+ const vroute = external ? external.route : route;
1141
+ const openItem = vroute.r === "detail" ? (items.find((i) => i.id === vroute.id) ?? null) : null;
1142
+ return (<View className="relative self-center overflow-hidden rounded-xl border border-border bg-background" style={[vaultVars(dark), { width: EXTENSION_POPUP_WIDTH, height }]}
1143
+ // A driven popup is a picture, not a control: taps would fight the scene.
1144
+ pointerEvents={driven ? "none" : "auto"}>
1145
+ {vroute.r === "detail" && openItem ? (<ItemDetail item={openItem} ink={ink} onBack={() => setRoute({ r: "main" })} onDelete={() => {
1146
+ const id = openItem.id;
1147
+ setItems((s) => s.filter((i) => i.id !== id));
1148
+ setRoute({ r: "main" });
1149
+ }} forceCopiedLabel={driven ? external?.autoCopied : undefined} scrollRef={external?.detailScrollRef}/>) : vroute.r === "settings" ? (<ExtSettings ink={ink} onBack={() => setRoute({ r: "main" })}/>) : vroute.r === "new" ? (<ExtItemForm ink={ink} onCancel={() => setRoute({ r: "main" })}/>) : (<View className="flex-1">
1150
+ {/* Vault + Tags tabs show the logo header (matches the app); Send and
1151
+ Secrets are full-screen "coming soon" with no header. */}
1152
+ {(tab === "vault" || tab === "tags") && (<ExtHeader logo={logo} tab={tab} ink={ink} onAdd={() => (tab === "tags" ? setModal("tag") : setRoute({ r: "new" }))} onAddFolder={() => setModal("folder")} onSettings={() => setRoute({ r: "settings" })}/>)}
1153
+
1154
+ <View className="flex-1">
1155
+ {tab === "vault" && (<VaultList ink={ink} items={items} onOpen={(id) => setRoute({ r: "detail", id })} scrollRef={external?.listScrollRef} highlightId={external?.highlightId}/>)}
1156
+ {tab === "tags" && <ExtTagsTab ink={ink}/>}
1157
+ {tab === "send" && (<ExtComingSoon ink={ink} icon={<Send size={28} color={ink.muted}/>} desc="Send files securely via link or email with permissions, auto-expiry, and encryption."/>)}
1158
+ {tab === "secrets" && (<ExtComingSoon ink={ink} icon={<KeyRound size={28} color={ink.muted}/>} desc="Manage API keys, tokens, environment variables, and other developer secrets."/>)}
1159
+ </View>
1160
+
1161
+ <ExtBottomNav active={tab} onChange={(t) => {
1162
+ setTab(t);
1163
+ setRoute({ r: "main" });
1164
+ }} ink={ink}/>
1165
+
1166
+ {modal === "tag" && <ExtTagModal ink={ink} onClose={() => setModal(null)}/>}
1167
+ {modal === "folder" && <ExtFolderModal ink={ink} onClose={() => setModal(null)}/>}
1168
+ </View>)}
1169
+ </View>);
1170
+ }
1171
+ export default ExtensionPopup;
1172
+ //# sourceMappingURL=extension-popup.jsx.map