@circuitwall/jarela 1.4.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 (97) 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/providers/[provider]/probe/route.js +9 -1
  27. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js.map +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +33 -8
  29. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js.map +1 -1
  30. package/.next/standalone/.next/server/app/page.js +63 -202
  31. package/.next/standalone/.next/server/app/page.js.map +1 -1
  32. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/setup/page.js +1 -1
  35. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/chunks/1718.js +159 -0
  38. package/.next/standalone/.next/server/chunks/1718.js.map +1 -0
  39. package/.next/standalone/.next/server/chunks/2082.js +6 -3
  40. package/.next/standalone/.next/server/chunks/2082.js.map +1 -1
  41. package/.next/standalone/.next/server/chunks/210.js +28 -0
  42. package/.next/standalone/.next/server/chunks/210.js.map +1 -1
  43. package/.next/standalone/.next/server/chunks/423.js +6 -3
  44. package/.next/standalone/.next/server/chunks/423.js.map +1 -1
  45. package/.next/standalone/.next/server/chunks/4631.js +37 -5
  46. package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
  47. package/.next/standalone/.next/server/chunks/8167.js +255 -204
  48. package/.next/standalone/.next/server/chunks/8167.js.map +1 -1
  49. package/.next/standalone/.next/server/chunks/8866.js +38 -5
  50. package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
  51. package/.next/standalone/.next/server/chunks/9032.js +8 -0
  52. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  53. package/.next/standalone/.next/server/chunks/{7883.js → 9557.js} +15 -3
  54. package/.next/standalone/.next/server/chunks/9557.js.map +1 -0
  55. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  56. package/.next/standalone/.next/server/middleware.js +6 -3
  57. package/.next/standalone/.next/server/pages/404.html +2 -2
  58. package/.next/standalone/.next/server/pages/500.html +1 -1
  59. package/.next/standalone/.next/server/proxy.js.map +1 -1
  60. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  61. package/.next/standalone/.next/static/chunks/{2351-68d8987bbe17ba2d.js → 2351-1ab119fb3b48f4c9.js} +258 -205
  62. package/.next/standalone/.next/static/chunks/2351-1ab119fb3b48f4c9.js.map +1 -0
  63. package/.next/standalone/.next/static/chunks/{9209-0d46118e502f8bf5.js → 4097-64691f9110cf167c.js} +14 -2
  64. package/.next/standalone/.next/static/chunks/4097-64691f9110cf167c.js.map +1 -0
  65. package/.next/standalone/.next/static/chunks/app/{page-74846c864241b96d.js → page-145150e0468544e7.js} +64 -203
  66. package/.next/standalone/.next/static/chunks/app/page-145150e0468544e7.js.map +1 -0
  67. package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js → page-a1463a9ace439ff7.js} +2 -2
  68. package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js.map → page-a1463a9ace439ff7.js.map} +1 -1
  69. package/.next/standalone/.next/static/chunks/{webpack-ff5627013a5e3842.js → webpack-f4ac5c5f92cfd1c1.js} +13 -1
  70. package/.next/standalone/.next/static/chunks/webpack-f4ac5c5f92cfd1c1.js.map +1 -0
  71. package/.next/standalone/package.json +1 -1
  72. package/CHANGELOG.md +60 -0
  73. package/README.md +1 -1
  74. package/api/client.ts +10 -9
  75. package/app/api/v1/dashboard/currency/route.ts +7 -2
  76. package/app/api/v1/providers/[provider]/probe/route.ts +12 -1
  77. package/app/api/v1/threads/[thread_id]/run/route.ts +22 -8
  78. package/components/layout/AppShell.tsx +53 -17
  79. package/components/setup/PinKeypad.tsx +238 -0
  80. package/components/setup/ScreenLock.tsx +8 -173
  81. package/components/setup/UnlockScreen.tsx +25 -192
  82. package/lib/documents/remote/github.ts +16 -2
  83. package/lib/documents/remote/mail.ts +11 -2
  84. package/lib/lifecycle/shutdown.ts +9 -0
  85. package/lib/providers/github-copilot-auth.ts +2 -0
  86. package/lib/providers/github-copilot.ts +1 -0
  87. package/lib/tools/async-results.ts +11 -0
  88. package/package.json +1 -1
  89. package/scripts/install-to-system.ps1 +2 -2
  90. package/scripts/installed-launcher.ps1 +81 -17
  91. package/.next/standalone/.next/server/chunks/7883.js.map +0 -1
  92. package/.next/standalone/.next/static/chunks/2351-68d8987bbe17ba2d.js.map +0 -1
  93. package/.next/standalone/.next/static/chunks/9209-0d46118e502f8bf5.js.map +0 -1
  94. package/.next/standalone/.next/static/chunks/app/page-74846c864241b96d.js.map +0 -1
  95. package/.next/standalone/.next/static/chunks/webpack-ff5627013a5e3842.js.map +0 -1
  96. /package/.next/standalone/.next/static/{AV5AO0yTRABo-NgwxhDe7 → WQdcnm9NyqpeNc0Z8_woo}/_buildManifest.js +0 -0
  97. /package/.next/standalone/.next/static/{AV5AO0yTRABo-NgwxhDe7 → 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
  }
@@ -216,10 +216,23 @@ async function runGithubPullsIndexer(source: DocumentSourceRow): Promise<GithubI
216
216
  if (Number.isFinite(sinceMs) && Number.isFinite(updatedMs) && updatedMs <= sinceMs) break outer;
217
217
 
218
218
  try {
219
- const [comments, reviews] = await Promise.all([
219
+ // allSettled so a failed comments fetch doesn't also discard
220
+ // the reviews (and vice versa); we still index what we have and
221
+ // count the partial as an error.
222
+ const [commentsRes, reviewsRes] = await Promise.allSettled([
220
223
  listIssueComments(auth, cfg.owner, cfg.repo, pr.number),
221
224
  listReviews(auth, cfg.owner, cfg.repo, pr.number),
222
225
  ]);
226
+ const comments = commentsRes.status === "fulfilled" ? commentsRes.value : [];
227
+ const reviews = reviewsRes.status === "fulfilled" ? reviewsRes.value : [];
228
+ if (commentsRes.status === "rejected") {
229
+ stats.errors++;
230
+ console.warn(`[github-indexer] pr#${pr.number} comments failed:`, commentsRes.reason);
231
+ }
232
+ if (reviewsRes.status === "rejected") {
233
+ stats.errors++;
234
+ console.warn(`[github-indexer] pr#${pr.number} reviews failed:`, reviewsRes.reason);
235
+ }
223
236
  const text = flattenPull(pr, comments, reviews);
224
237
  const res = await upsertRemoteDocument(source.id, {
225
238
  path: `github-pull://${cfg.owner}/${cfg.repo}/${pr.number}`,
@@ -229,8 +242,9 @@ async function runGithubPullsIndexer(source: DocumentSourceRow): Promise<GithubI
229
242
  });
230
243
  applyUpsert(stats, res);
231
244
  if (updated && (!highWater || updated > highWater)) highWater = updated;
232
- } catch {
245
+ } catch (err) {
233
246
  stats.errors++;
247
+ console.warn(`[github-indexer] pr#${pr.number} upsert failed:`, err);
234
248
  }
235
249
  }
236
250
  if (pulls.length < PR_PAGE_LIMIT) break;