@circuitwall/jarela 1.3.0 → 1.4.1

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 (102) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +10 -1
  23. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js +10 -5
  25. package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js.map +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +37 -3
  27. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js.map +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +9 -1
  29. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js.map +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +33 -8
  31. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js.map +1 -1
  32. package/.next/standalone/.next/server/app/page.js +73 -204
  33. package/.next/standalone/.next/server/app/page.js.map +1 -1
  34. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/setup/page.js +1 -1
  37. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  39. package/.next/standalone/.next/server/chunks/1718.js +159 -0
  40. package/.next/standalone/.next/server/chunks/1718.js.map +1 -0
  41. package/.next/standalone/.next/server/chunks/2082.js +6 -3
  42. package/.next/standalone/.next/server/chunks/2082.js.map +1 -1
  43. package/.next/standalone/.next/server/chunks/210.js +28 -0
  44. package/.next/standalone/.next/server/chunks/210.js.map +1 -1
  45. package/.next/standalone/.next/server/chunks/423.js +6 -3
  46. package/.next/standalone/.next/server/chunks/423.js.map +1 -1
  47. package/.next/standalone/.next/server/chunks/4631.js +37 -5
  48. package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
  49. package/.next/standalone/.next/server/chunks/8167.js +255 -204
  50. package/.next/standalone/.next/server/chunks/8167.js.map +1 -1
  51. package/.next/standalone/.next/server/chunks/8866.js +38 -5
  52. package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
  53. package/.next/standalone/.next/server/chunks/9032.js +8 -0
  54. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  55. package/.next/standalone/.next/server/chunks/{7883.js → 9557.js} +15 -3
  56. package/.next/standalone/.next/server/chunks/9557.js.map +1 -0
  57. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  58. package/.next/standalone/.next/server/middleware.js +6 -3
  59. package/.next/standalone/.next/server/pages/404.html +2 -2
  60. package/.next/standalone/.next/server/pages/500.html +1 -1
  61. package/.next/standalone/.next/server/proxy.js.map +1 -1
  62. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  63. package/.next/standalone/.next/static/chunks/{2351-68d8987bbe17ba2d.js → 2351-1ab119fb3b48f4c9.js} +258 -205
  64. package/.next/standalone/.next/static/chunks/2351-1ab119fb3b48f4c9.js.map +1 -0
  65. package/.next/standalone/.next/static/chunks/{9209-0d46118e502f8bf5.js → 4097-64691f9110cf167c.js} +14 -2
  66. package/.next/standalone/.next/static/chunks/4097-64691f9110cf167c.js.map +1 -0
  67. package/.next/standalone/.next/static/chunks/app/{page-2ab710949b62a638.js → page-145150e0468544e7.js} +74 -205
  68. package/.next/standalone/.next/static/chunks/app/page-145150e0468544e7.js.map +1 -0
  69. package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js → page-a1463a9ace439ff7.js} +2 -2
  70. package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js.map → page-a1463a9ace439ff7.js.map} +1 -1
  71. package/.next/standalone/.next/static/chunks/{webpack-ff5627013a5e3842.js → webpack-f4ac5c5f92cfd1c1.js} +13 -1
  72. package/.next/standalone/.next/static/chunks/webpack-f4ac5c5f92cfd1c1.js.map +1 -0
  73. package/.next/standalone/package.json +2 -1
  74. package/CHANGELOG.md +84 -0
  75. package/README.md +51 -26
  76. package/api/client.ts +10 -9
  77. package/app/api/v1/dashboard/currency/route.ts +7 -2
  78. package/app/api/v1/providers/[provider]/probe/route.ts +12 -1
  79. package/app/api/v1/threads/[thread_id]/run/route.ts +22 -8
  80. package/components/chat/InputBar.tsx +10 -1
  81. package/components/layout/AppShell.tsx +53 -17
  82. package/components/setup/PinKeypad.tsx +238 -0
  83. package/components/setup/ScreenLock.tsx +8 -173
  84. package/components/setup/UnlockScreen.tsx +25 -192
  85. package/lib/api/page-capture.test.ts +58 -0
  86. package/lib/api/page-capture.ts +31 -1
  87. package/lib/documents/remote/github.ts +16 -2
  88. package/lib/documents/remote/mail.ts +11 -2
  89. package/lib/lifecycle/shutdown.ts +9 -0
  90. package/lib/providers/github-copilot-auth.ts +2 -0
  91. package/lib/providers/github-copilot.ts +1 -0
  92. package/lib/tools/async-results.ts +11 -0
  93. package/package.json +2 -1
  94. package/scripts/install-to-system.ps1 +2 -2
  95. package/scripts/installed-launcher.ps1 +81 -17
  96. package/.next/standalone/.next/server/chunks/7883.js.map +0 -1
  97. package/.next/standalone/.next/static/chunks/2351-68d8987bbe17ba2d.js.map +0 -1
  98. package/.next/standalone/.next/static/chunks/9209-0d46118e502f8bf5.js.map +0 -1
  99. package/.next/standalone/.next/static/chunks/app/page-2ab710949b62a638.js.map +0 -1
  100. package/.next/standalone/.next/static/chunks/webpack-ff5627013a5e3842.js.map +0 -1
  101. /package/.next/standalone/.next/static/{ZKy7LJ3KXj2TIyKOg_fBH → WQdcnm9NyqpeNc0Z8_woo}/_buildManifest.js +0 -0
  102. /package/.next/standalone/.next/static/{ZKy7LJ3KXj2TIyKOg_fBH → WQdcnm9NyqpeNc0Z8_woo}/_ssgManifest.js +0 -0
@@ -0,0 +1,238 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Logo } from "@/components/ui/Logo";
5
+
6
+ // Shared 6-digit PIN keypad used by both the decrypt splash (master key
7
+ // locked at boot) and the screen-lock overlay (idle timer fired). The
8
+ // only differences between the two states are (a) the endpoint hit
9
+ // (b) the copy on screen and (c) what happens after success. Everything
10
+ // else — keypad layout, dot strip, rate-limit handling, keyboard
11
+ // support, error mapping — is identical, so the two were collapsed into
12
+ // one component to avoid drift.
13
+
14
+ const PIN_LENGTH = 6;
15
+
16
+ export type PinKeypadMode = "decrypt" | "unlock";
17
+
18
+ interface PinKeypadProps {
19
+ mode: PinKeypadMode;
20
+ onSuccess: () => void;
21
+ }
22
+
23
+ interface ModeConfig {
24
+ endpoint: string;
25
+ title: string;
26
+ subtitle: string;
27
+ busyLabel: string;
28
+ }
29
+
30
+ const MODES: Record<PinKeypadMode, ModeConfig> = {
31
+ decrypt: {
32
+ endpoint: "/api/v1/security/unlock",
33
+ title: "Decrypt Jarela",
34
+ subtitle: "Enter your 6-digit PIN to decrypt your data.",
35
+ busyLabel: "Decrypting\u2026",
36
+ },
37
+ unlock: {
38
+ endpoint: "/api/v1/security/verify-pin",
39
+ title: "Welcome back",
40
+ subtitle: "Enter your 6-digit PIN to unlock.",
41
+ busyLabel: "Unlocking\u2026",
42
+ },
43
+ };
44
+
45
+ export function PinKeypad({ mode, onSuccess }: PinKeypadProps) {
46
+ const cfg = MODES[mode];
47
+ const [digits, setDigits] = useState("");
48
+ const [error, setError] = useState<string | null>(null);
49
+ const [submitting, setSubmitting] = useState(false);
50
+ const [retryAfterSec, setRetryAfterSec] = useState(0);
51
+ // Hard guard against parallel submits. setState updaters can run more
52
+ // than once (dev StrictMode, concurrent rendering), so if `submit`
53
+ // were called from inside `setDigits` we'd POST twice and the second
54
+ // request would race the first into `unlockMasterKey()` after state
55
+ // already flipped to unlocked — the route would 500.
56
+ const submittingRef = useRef(false);
57
+
58
+ const submit = useCallback(
59
+ async (pin: string) => {
60
+ if (submittingRef.current) return;
61
+ submittingRef.current = true;
62
+ setSubmitting(true);
63
+ setError(null);
64
+ try {
65
+ const res = await fetch(cfg.endpoint, {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json" },
68
+ body: JSON.stringify({ pin }),
69
+ });
70
+ if (res.ok) {
71
+ onSuccess();
72
+ return;
73
+ }
74
+ const body = (await res.json().catch(() => ({}))) as {
75
+ error?: string;
76
+ retry_after_ms?: number;
77
+ };
78
+ // Goal-state convergence: both endpoints can return 409 when the
79
+ // server already reached the target state (decrypt → master key
80
+ // already unlocked; unlock → screen not locked). Treat as success
81
+ // rather than surfacing a confusing error.
82
+ if (res.status === 409 && (body.error === "not-locked" || body.error === "no-pin")) {
83
+ onSuccess();
84
+ return;
85
+ }
86
+ if (res.status === 429 && typeof body.retry_after_ms === "number") {
87
+ setRetryAfterSec(Math.ceil(body.retry_after_ms / 1000));
88
+ setError("Too many attempts. Try again later.");
89
+ } else if (res.status === 401) {
90
+ setError("Wrong PIN. Try again.");
91
+ } else if (res.status === 400) {
92
+ setError("Invalid PIN format.");
93
+ } else {
94
+ setError(body.error ?? `Error (${res.status})`);
95
+ }
96
+ setDigits("");
97
+ } catch (err) {
98
+ setError(err instanceof Error ? err.message : String(err));
99
+ setDigits("");
100
+ } finally {
101
+ submittingRef.current = false;
102
+ setSubmitting(false);
103
+ }
104
+ },
105
+ [cfg.endpoint, onSuccess],
106
+ );
107
+
108
+ const append = useCallback(
109
+ (d: string) => {
110
+ if (submitting || retryAfterSec > 0) return;
111
+ setError(null);
112
+ setDigits((cur) => (cur.length >= PIN_LENGTH ? cur : cur + d));
113
+ },
114
+ [submitting, retryAfterSec],
115
+ );
116
+
117
+ // Auto-submit once the buffer hits 6 digits.
118
+ useEffect(() => {
119
+ if (digits.length === PIN_LENGTH && !submittingRef.current) {
120
+ void submit(digits);
121
+ }
122
+ }, [digits, submit]);
123
+
124
+ const backspace = useCallback(() => {
125
+ if (submitting) return;
126
+ setError(null);
127
+ setDigits((cur) => cur.slice(0, -1));
128
+ }, [submitting]);
129
+
130
+ useEffect(() => {
131
+ function onKey(e: KeyboardEvent) {
132
+ if (/^[0-9]$/.test(e.key)) {
133
+ e.preventDefault();
134
+ append(e.key);
135
+ } else if (e.key === "Backspace") {
136
+ e.preventDefault();
137
+ backspace();
138
+ }
139
+ }
140
+ window.addEventListener("keydown", onKey);
141
+ return () => window.removeEventListener("keydown", onKey);
142
+ }, [append, backspace]);
143
+
144
+ useEffect(() => {
145
+ if (retryAfterSec <= 0) return;
146
+ const t = setInterval(() => {
147
+ setRetryAfterSec((s) => (s > 0 ? s - 1 : 0));
148
+ }, 1000);
149
+ return () => clearInterval(t);
150
+ }, [retryAfterSec]);
151
+
152
+ return (
153
+ <div
154
+ className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-6 bg-surface text-fg animate-in fade-in duration-200"
155
+ style={{
156
+ paddingTop: "env(safe-area-inset-top)",
157
+ paddingBottom: "env(safe-area-inset-bottom)",
158
+ }}
159
+ data-pin-mode={mode}
160
+ >
161
+ <Logo className="h-16 w-auto" />
162
+ <div className="w-full max-w-xs p-6">
163
+ <h1 className="mb-1 text-center text-lg font-semibold text-fg">{cfg.title}</h1>
164
+ <p className="mb-6 text-center text-xs text-fg-faint">{cfg.subtitle}</p>
165
+
166
+ <div className="mb-6 flex justify-center gap-3" aria-label="PIN entry progress">
167
+ {Array.from({ length: PIN_LENGTH }).map((_, i) => (
168
+ <span
169
+ key={i}
170
+ className={`h-3 w-3 rounded-full transition-colors ${
171
+ i < digits.length ? (error ? "bg-red-500" : "bg-fg") : "bg-surface-3"
172
+ }`}
173
+ />
174
+ ))}
175
+ </div>
176
+
177
+ <div className="grid grid-cols-3 gap-2">
178
+ {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
179
+ <PinKey
180
+ key={d}
181
+ digit={d}
182
+ onPress={() => append(d)}
183
+ disabled={submitting || retryAfterSec > 0}
184
+ />
185
+ ))}
186
+ <div />
187
+ <PinKey
188
+ digit="0"
189
+ onPress={() => append("0")}
190
+ disabled={submitting || retryAfterSec > 0}
191
+ />
192
+ <PinKey
193
+ digit="\u2190"
194
+ onPress={backspace}
195
+ disabled={submitting || digits.length === 0}
196
+ ariaLabel="Backspace"
197
+ />
198
+ </div>
199
+
200
+ <p
201
+ className={`mt-4 min-h-[1.5rem] text-center text-xs ${
202
+ error ? "text-red-400" : "text-fg-faint"
203
+ }`}
204
+ role="status"
205
+ aria-live="polite"
206
+ >
207
+ {retryAfterSec > 0
208
+ ? `Try again in ${retryAfterSec}s`
209
+ : error ?? (submitting ? cfg.busyLabel : "\u00A0")}
210
+ </p>
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function PinKey({
217
+ digit,
218
+ onPress,
219
+ disabled,
220
+ ariaLabel,
221
+ }: {
222
+ digit: string;
223
+ onPress: () => void;
224
+ disabled: boolean;
225
+ ariaLabel?: string;
226
+ }) {
227
+ return (
228
+ <button
229
+ type="button"
230
+ onClick={onPress}
231
+ disabled={disabled}
232
+ aria-label={ariaLabel ?? digit}
233
+ className="h-14 rounded-xl bg-surface-3 text-xl font-medium text-fg transition-colors hover:bg-surface-3/70 active:bg-surface-3/50 disabled:cursor-not-allowed disabled:opacity-50"
234
+ >
235
+ {digit}
236
+ </button>
237
+ );
238
+ }
@@ -1,179 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
- import { Logo } from "@/components/ui/Logo";
3
+ import { PinKeypad } from "./PinKeypad";
5
4
 
6
- // Screen-lock overlay (presence check). Shown when the server reports
7
- // `screen_locked: true` after an idle timeout. Distinct from
8
- // UnlockScreen — this does NOT touch the in-memory master key, just
9
- // verifies the human at the keyboard knows the PIN. Background work
10
- // (agents, scheduler, bridges) keeps running underneath.
11
-
12
- const PIN_LENGTH = 6;
5
+ // Screen-lock overlay (presence check). Mounted by AppShell when the
6
+ // idle timer fires or the server returns 423 `screen-locked`. Does
7
+ // NOT touch the in-memory master key — background work (agents,
8
+ // scheduler, bridges) keeps running underneath. The /verify-pin
9
+ // endpoint just confirms the human at the keyboard and clears the
10
+ // idle flag.
13
11
 
14
12
  export function ScreenLock({ onUnlock }: { onUnlock: () => void }) {
15
- const [digits, setDigits] = useState("");
16
- const [error, setError] = useState<string | null>(null);
17
- const [submitting, setSubmitting] = useState(false);
18
- const [retryAfterSec, setRetryAfterSec] = useState(0);
19
- const submittingRef = useRef(false);
20
-
21
- const submit = useCallback(async (pin: string) => {
22
- if (submittingRef.current) return;
23
- submittingRef.current = true;
24
- setSubmitting(true);
25
- setError(null);
26
- try {
27
- const res = await fetch("/api/v1/security/verify-pin", {
28
- method: "POST",
29
- headers: { "content-type": "application/json" },
30
- body: JSON.stringify({ pin }),
31
- });
32
- if (res.ok) {
33
- onUnlock();
34
- return;
35
- }
36
- const body = (await res.json().catch(() => ({}))) as {
37
- error?: string;
38
- retry_after_ms?: number;
39
- };
40
- if (res.status === 429 && typeof body.retry_after_ms === "number") {
41
- setRetryAfterSec(Math.ceil(body.retry_after_ms / 1000));
42
- setError("Too many attempts. Try again later.");
43
- } else if (res.status === 401) {
44
- setError("Wrong PIN. Try again.");
45
- } else if (res.status === 400) {
46
- setError("Invalid PIN format.");
47
- } else {
48
- setError(body.error ?? `Error (${res.status})`);
49
- }
50
- setDigits("");
51
- } catch (err) {
52
- setError(err instanceof Error ? err.message : String(err));
53
- setDigits("");
54
- } finally {
55
- submittingRef.current = false;
56
- setSubmitting(false);
57
- }
58
- }, [onUnlock]);
59
-
60
- const append = useCallback((d: string) => {
61
- if (submitting || retryAfterSec > 0) return;
62
- setError(null);
63
- setDigits((cur) => (cur.length >= PIN_LENGTH ? cur : cur + d));
64
- }, [submitting, retryAfterSec]);
65
-
66
- useEffect(() => {
67
- if (digits.length === PIN_LENGTH && !submittingRef.current) {
68
- void submit(digits);
69
- }
70
- }, [digits, submit]);
71
-
72
- const backspace = useCallback(() => {
73
- if (submitting) return;
74
- setError(null);
75
- setDigits((cur) => cur.slice(0, -1));
76
- }, [submitting]);
77
-
78
- useEffect(() => {
79
- function onKey(e: KeyboardEvent) {
80
- if (/^[0-9]$/.test(e.key)) {
81
- e.preventDefault();
82
- append(e.key);
83
- } else if (e.key === "Backspace") {
84
- e.preventDefault();
85
- backspace();
86
- }
87
- }
88
- window.addEventListener("keydown", onKey);
89
- return () => window.removeEventListener("keydown", onKey);
90
- }, [append, backspace]);
91
-
92
- useEffect(() => {
93
- if (retryAfterSec <= 0) return;
94
- const t = setInterval(() => {
95
- setRetryAfterSec((s) => (s > 0 ? s - 1 : 0));
96
- }, 1000);
97
- return () => clearInterval(t);
98
- }, [retryAfterSec]);
99
-
100
- return (
101
- <div
102
- className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-6 bg-surface text-fg"
103
- style={{
104
- paddingTop: "env(safe-area-inset-top)",
105
- paddingBottom: "env(safe-area-inset-bottom)",
106
- }}
107
- >
108
- <Logo className="h-16 w-auto" />
109
- <div className="w-full max-w-xs p-6">
110
- <h1 className="mb-1 text-center text-lg font-semibold text-fg">
111
- Locked
112
- </h1>
113
- <p className="mb-6 text-center text-xs text-fg-faint">
114
- Enter your 6-digit PIN to resume.
115
- </p>
116
-
117
- <div className="mb-6 flex justify-center gap-3" aria-label="PIN entry progress">
118
- {Array.from({ length: PIN_LENGTH }).map((_, i) => (
119
- <span
120
- key={i}
121
- className={`h-3 w-3 rounded-full transition-colors ${
122
- i < digits.length
123
- ? error
124
- ? "bg-red-500"
125
- : "bg-fg"
126
- : "bg-surface-3"
127
- }`}
128
- />
129
- ))}
130
- </div>
131
-
132
- <div className="grid grid-cols-3 gap-2">
133
- {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
134
- <PinKey key={d} digit={d} onPress={() => append(d)} disabled={submitting || retryAfterSec > 0} />
135
- ))}
136
- <div />
137
- <PinKey digit="0" onPress={() => append("0")} disabled={submitting || retryAfterSec > 0} />
138
- <PinKey digit="←" onPress={backspace} disabled={submitting || digits.length === 0} ariaLabel="Backspace" />
139
- </div>
140
-
141
- <p
142
- className={`mt-4 min-h-[1.5rem] text-center text-xs ${
143
- error ? "text-red-400" : "text-fg-faint"
144
- }`}
145
- role="status"
146
- aria-live="polite"
147
- >
148
- {retryAfterSec > 0
149
- ? `Try again in ${retryAfterSec}s`
150
- : error ?? (submitting ? "Verifying…" : "\u00A0")}
151
- </p>
152
- </div>
153
- </div>
154
- );
155
- }
156
-
157
- function PinKey({
158
- digit,
159
- onPress,
160
- disabled,
161
- ariaLabel,
162
- }: {
163
- digit: string;
164
- onPress: () => void;
165
- disabled: boolean;
166
- ariaLabel?: string;
167
- }) {
168
- return (
169
- <button
170
- type="button"
171
- onClick={onPress}
172
- disabled={disabled}
173
- aria-label={ariaLabel ?? digit}
174
- className="h-14 rounded-xl bg-surface-3 text-xl font-medium text-fg transition-colors hover:bg-surface-3/70 active:bg-surface-3/50 disabled:cursor-not-allowed disabled:opacity-50"
175
- >
176
- {digit}
177
- </button>
178
- );
13
+ return <PinKeypad mode="unlock" onSuccess={onUnlock} />;
179
14
  }
@@ -1,197 +1,30 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
- import { Logo } from "@/components/ui/Logo";
5
-
6
- // PIN unlock splash for ADR-0063 PIN-wrapped keyfiles. Rendered by the
7
- // root route when the server detects the master key is locked. Submits
8
- // the 6-digit PIN to /api/v1/security/unlock; on success, reloads so
9
- // the server re-renders into the normal authenticated tree.
10
-
11
- const PIN_LENGTH = 6;
12
-
13
- export function UnlockScreen() {
14
- const [digits, setDigits] = useState("");
15
- const [error, setError] = useState<string | null>(null);
16
- const [submitting, setSubmitting] = useState(false);
17
- const [retryAfterSec, setRetryAfterSec] = useState(0);
18
- const containerRef = useRef<HTMLDivElement>(null);
19
- // Hard guard against parallel submits. setState updaters can run more
20
- // than once (dev StrictMode, concurrent rendering), so if `submit`
21
- // were called from inside `setDigits` we'd POST twice and the second
22
- // request would race the first into `unlockMasterKey()` after state
23
- // already flipped to unlocked - the route would 500.
24
- const submittingRef = useRef(false);
25
-
26
- const submit = useCallback(async (pin: string) => {
27
- if (submittingRef.current) return;
28
- submittingRef.current = true;
29
- setSubmitting(true);
30
- setError(null);
31
- try {
32
- const res = await fetch("/api/v1/security/unlock", {
33
- method: "POST",
34
- headers: { "content-type": "application/json" },
35
- body: JSON.stringify({ pin }),
36
- });
37
- if (res.ok) {
38
- window.location.reload();
39
- return;
40
- }
41
- const body = (await res.json().catch(() => ({}))) as {
42
- error?: string;
43
- retry_after_ms?: number;
44
- };
45
- if (res.status === 409 && body.error === "not-locked") {
46
- // Master key was already unlocked (host typed the PIN, or
47
- // another tab beat us to it). The goal state is reached —
48
- // reload into the app shell.
49
- window.location.reload();
50
- return;
51
- }
52
- if (res.status === 429 && typeof body.retry_after_ms === "number") {
53
- setRetryAfterSec(Math.ceil(body.retry_after_ms / 1000));
54
- setError("Too many attempts. Try again later.");
55
- } else if (res.status === 401) {
56
- setError("Wrong PIN. Try again.");
57
- } else if (res.status === 400) {
58
- setError("Invalid PIN format.");
59
- } else {
60
- setError(body.error ?? `Error (${res.status})`);
61
- }
62
- setDigits("");
63
- } catch (err) {
64
- setError(err instanceof Error ? err.message : String(err));
65
- setDigits("");
66
- } finally {
67
- submittingRef.current = false;
68
- setSubmitting(false);
69
- }
70
- }, []);
71
-
72
- const append = useCallback((d: string) => {
73
- if (submitting || retryAfterSec > 0) return;
74
- setError(null);
75
- setDigits((cur) => (cur.length >= PIN_LENGTH ? cur : cur + d));
76
- }, [submitting, retryAfterSec]);
77
-
78
- // Auto-submit once the buffer hits 6 digits. Effect runs once per
79
- // state transition (not per updater invocation), so we POST exactly
80
- // one time even under StrictMode double-render.
81
- useEffect(() => {
82
- if (digits.length === PIN_LENGTH && !submittingRef.current) {
83
- void submit(digits);
84
- }
85
- }, [digits, submit]);
86
-
87
- const backspace = useCallback(() => {
88
- if (submitting) return;
89
- setError(null);
90
- setDigits((cur) => cur.slice(0, -1));
91
- }, [submitting]);
92
-
93
- // Physical keyboard support.
94
- useEffect(() => {
95
- function onKey(e: KeyboardEvent) {
96
- if (/^[0-9]$/.test(e.key)) {
97
- e.preventDefault();
98
- append(e.key);
99
- } else if (e.key === "Backspace") {
100
- e.preventDefault();
101
- backspace();
102
- }
103
- }
104
- window.addEventListener("keydown", onKey);
105
- return () => window.removeEventListener("keydown", onKey);
106
- }, [append, backspace]);
107
-
108
- // Tick down the rate-limit countdown.
109
- useEffect(() => {
110
- if (retryAfterSec <= 0) return;
111
- const t = setInterval(() => {
112
- setRetryAfterSec((s) => (s > 0 ? s - 1 : 0));
113
- }, 1000);
114
- return () => clearInterval(t);
115
- }, [retryAfterSec]);
116
-
117
- return (
118
- <div
119
- ref={containerRef}
120
- className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-6 bg-surface text-fg"
121
- style={{
122
- paddingTop: "env(safe-area-inset-top)",
123
- paddingBottom: "env(safe-area-inset-bottom)",
124
- }}
125
- >
126
- <Logo className="h-16 w-auto" />
127
- <div className="w-full max-w-xs p-6">
128
- <h1 className="mb-1 text-center text-lg font-semibold text-fg">
129
- Unlock Jarela
130
- </h1>
131
- <p className="mb-6 text-center text-xs text-fg-faint">
132
- Enter your 6-digit PIN to decrypt your data.
133
- </p>
134
-
135
- <div className="mb-6 flex justify-center gap-3" aria-label="PIN entry progress">
136
- {Array.from({ length: PIN_LENGTH }).map((_, i) => (
137
- <span
138
- key={i}
139
- className={`h-3 w-3 rounded-full transition-colors ${
140
- i < digits.length
141
- ? error
142
- ? "bg-red-500"
143
- : "bg-fg"
144
- : "bg-surface-3"
145
- }`}
146
- />
147
- ))}
148
- </div>
149
-
150
- <div className="grid grid-cols-3 gap-2">
151
- {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
152
- <PinKey key={d} digit={d} onPress={() => append(d)} disabled={submitting || retryAfterSec > 0} />
153
- ))}
154
- <div />
155
- <PinKey digit="0" onPress={() => append("0")} disabled={submitting || retryAfterSec > 0} />
156
- <PinKey digit="←" onPress={backspace} disabled={submitting || digits.length === 0} ariaLabel="Backspace" />
157
- </div>
158
-
159
- <p
160
- className={`mt-4 min-h-[1.5rem] text-center text-xs ${
161
- error ? "text-red-400" : "text-fg-faint"
162
- }`}
163
- role="status"
164
- aria-live="polite"
165
- >
166
- {retryAfterSec > 0
167
- ? `Try again in ${retryAfterSec}s`
168
- : error ?? (submitting ? "Unlocking…" : "\u00A0")}
169
- </p>
170
- </div>
171
- </div>
172
- );
3
+ import { useRouter } from "next/navigation";
4
+ import { useCallback } from "react";
5
+ import { PinKeypad } from "./PinKeypad";
6
+
7
+ // Decrypt splash for ADR-0063 PIN-wrapped keyfiles. Two mount sites:
8
+ //
9
+ // - app/page.tsx — when the server detects the master key is locked
10
+ // at boot. No `onUnlock` prop, so we fall back to router.refresh()
11
+ // to re-run the server component without a hard reload; this gives
12
+ // a far smoother decrypt → AppShell handoff than location.reload().
13
+ //
14
+ // - AppShell when the master key gets re-locked mid-session and the
15
+ // API client dispatches `jarela:master-key-locked`. The shell wants
16
+ // to hide the overlay and drop the user on the agent picker without
17
+ // re-running the SSR boundary, so it passes `onUnlock`.
18
+
19
+ interface Props {
20
+ onUnlock?: () => void;
173
21
  }
174
22
 
175
- function PinKey({
176
- digit,
177
- onPress,
178
- disabled,
179
- ariaLabel,
180
- }: {
181
- digit: string;
182
- onPress: () => void;
183
- disabled: boolean;
184
- ariaLabel?: string;
185
- }) {
186
- return (
187
- <button
188
- type="button"
189
- onClick={onPress}
190
- disabled={disabled}
191
- aria-label={ariaLabel ?? digit}
192
- className="h-14 rounded-xl bg-surface-3 text-xl font-medium text-fg transition-colors hover:bg-surface-3/70 active:bg-surface-3/50 disabled:cursor-not-allowed disabled:opacity-50"
193
- >
194
- {digit}
195
- </button>
196
- );
23
+ export function UnlockScreen({ onUnlock }: Props = {}) {
24
+ const router = useRouter();
25
+ const onSuccess = useCallback(() => {
26
+ if (onUnlock) onUnlock();
27
+ else router.refresh();
28
+ }, [onUnlock, router]);
29
+ return <PinKeypad mode="decrypt" onSuccess={onSuccess} />;
197
30
  }