@entropy-softworks/ui 2026.8.35 → 2026.8.37
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.
- package/lib/components/auth/PasswordStrengthMeter.d.ts +9 -0
- package/lib/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
- package/lib/components/auth/PasswordStrengthMeter.jsx +10 -4
- package/lib/components/auth/PasswordStrengthMeter.jsx.map +1 -1
- package/lib/components/index.d.ts +1 -0
- package/lib/components/index.d.ts.map +1 -1
- package/lib/components/index.js +1 -0
- package/lib/components/index.js.map +1 -1
- package/lib/components/profile/SecurityTab.d.ts +5 -1
- package/lib/components/profile/SecurityTab.d.ts.map +1 -1
- package/lib/components/profile/SecurityTab.jsx.map +1 -1
- package/lib/components/segmented-toggle.d.ts +9 -0
- package/lib/components/segmented-toggle.d.ts.map +1 -1
- package/lib/components/segmented-toggle.jsx +4 -0
- package/lib/components/segmented-toggle.jsx.map +1 -1
- package/lib/components/social-connections.d.ts +22 -5
- package/lib/components/social-connections.d.ts.map +1 -1
- package/lib/components/social-connections.jsx +37 -11
- package/lib/components/social-connections.jsx.map +1 -1
- package/lib/components/update-overlay.d.ts +41 -0
- package/lib/components/update-overlay.d.ts.map +1 -0
- package/lib/components/update-overlay.jsx +132 -0
- package/lib/components/update-overlay.jsx.map +1 -0
- package/lib/context/AppConfigContext.d.ts +27 -0
- package/lib/context/AppConfigContext.d.ts.map +1 -1
- package/lib/context/AppConfigContext.jsx.map +1 -1
- package/lib/demos/extension-popup.d.ts +212 -0
- package/lib/demos/extension-popup.d.ts.map +1 -0
- package/lib/demos/extension-popup.jsx +1172 -0
- package/lib/demos/extension-popup.jsx.map +1 -0
- package/lib/demos/index.d.ts +11 -0
- package/lib/demos/index.d.ts.map +1 -0
- package/lib/demos/index.js +14 -0
- package/lib/demos/index.js.map +1 -0
- package/lib/screens/auth/AuthScreen.d.ts +75 -1
- package/lib/screens/auth/AuthScreen.d.ts.map +1 -1
- package/lib/screens/auth/AuthScreen.jsx +356 -122
- package/lib/screens/auth/AuthScreen.jsx.map +1 -1
- package/lib/services/auth.service.d.ts +5 -1
- package/lib/services/auth.service.d.ts.map +1 -1
- package/lib/services/auth.service.js +5 -1
- package/lib/services/auth.service.js.map +1 -1
- package/package.json +7 -1
- package/src/components/auth/PasswordStrengthMeter.tsx +25 -3
- package/src/components/index.ts +1 -0
- package/src/components/profile/SecurityTab.tsx +11 -1
- package/src/components/segmented-toggle.tsx +13 -0
- package/src/components/social-connections.tsx +66 -20
- package/src/components/update-overlay.tsx +206 -0
- package/src/context/AppConfigContext.tsx +27 -0
- package/src/demos/extension-popup.tsx +2051 -0
- package/src/demos/index.ts +38 -0
- package/src/screens/auth/AuthScreen.tsx +701 -312
- package/src/services/auth.service.ts +13 -2
|
@@ -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 "{item.name}"? This can'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'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'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;
|