@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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +10 -1
- package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js +10 -5
- package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +9 -1
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +33 -8
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js.map +1 -1
- package/.next/standalone/.next/server/app/page.js +63 -202
- package/.next/standalone/.next/server/app/page.js.map +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/1718.js +159 -0
- package/.next/standalone/.next/server/chunks/1718.js.map +1 -0
- package/.next/standalone/.next/server/chunks/2082.js +6 -3
- package/.next/standalone/.next/server/chunks/2082.js.map +1 -1
- package/.next/standalone/.next/server/chunks/210.js +28 -0
- package/.next/standalone/.next/server/chunks/210.js.map +1 -1
- package/.next/standalone/.next/server/chunks/423.js +6 -3
- package/.next/standalone/.next/server/chunks/423.js.map +1 -1
- package/.next/standalone/.next/server/chunks/4631.js +37 -5
- package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8167.js +255 -204
- package/.next/standalone/.next/server/chunks/8167.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8866.js +38 -5
- package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
- package/.next/standalone/.next/server/chunks/9032.js +8 -0
- package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
- package/.next/standalone/.next/server/chunks/{7883.js → 9557.js} +15 -3
- package/.next/standalone/.next/server/chunks/9557.js.map +1 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware.js +6 -3
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/proxy.js.map +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{2351-68d8987bbe17ba2d.js → 2351-1ab119fb3b48f4c9.js} +258 -205
- package/.next/standalone/.next/static/chunks/2351-1ab119fb3b48f4c9.js.map +1 -0
- package/.next/standalone/.next/static/chunks/{9209-0d46118e502f8bf5.js → 4097-64691f9110cf167c.js} +14 -2
- package/.next/standalone/.next/static/chunks/4097-64691f9110cf167c.js.map +1 -0
- package/.next/standalone/.next/static/chunks/app/{page-74846c864241b96d.js → page-145150e0468544e7.js} +64 -203
- package/.next/standalone/.next/static/chunks/app/page-145150e0468544e7.js.map +1 -0
- package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js → page-a1463a9ace439ff7.js} +2 -2
- package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js.map → page-a1463a9ace439ff7.js.map} +1 -1
- package/.next/standalone/.next/static/chunks/{webpack-ff5627013a5e3842.js → webpack-f4ac5c5f92cfd1c1.js} +13 -1
- package/.next/standalone/.next/static/chunks/webpack-f4ac5c5f92cfd1c1.js.map +1 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +1 -1
- package/api/client.ts +10 -9
- package/app/api/v1/dashboard/currency/route.ts +7 -2
- package/app/api/v1/providers/[provider]/probe/route.ts +12 -1
- package/app/api/v1/threads/[thread_id]/run/route.ts +22 -8
- package/components/layout/AppShell.tsx +53 -17
- package/components/setup/PinKeypad.tsx +238 -0
- package/components/setup/ScreenLock.tsx +8 -173
- package/components/setup/UnlockScreen.tsx +25 -192
- package/lib/documents/remote/github.ts +16 -2
- package/lib/documents/remote/mail.ts +11 -2
- package/lib/lifecycle/shutdown.ts +9 -0
- package/lib/providers/github-copilot-auth.ts +2 -0
- package/lib/providers/github-copilot.ts +1 -0
- package/lib/tools/async-results.ts +11 -0
- package/package.json +1 -1
- package/scripts/install-to-system.ps1 +2 -2
- package/scripts/installed-launcher.ps1 +81 -17
- package/.next/standalone/.next/server/chunks/7883.js.map +0 -1
- package/.next/standalone/.next/static/chunks/2351-68d8987bbe17ba2d.js.map +0 -1
- package/.next/standalone/.next/static/chunks/9209-0d46118e502f8bf5.js.map +0 -1
- package/.next/standalone/.next/static/chunks/app/page-74846c864241b96d.js.map +0 -1
- package/.next/standalone/.next/static/chunks/webpack-ff5627013a5e3842.js.map +0 -1
- /package/.next/standalone/.next/static/{AV5AO0yTRABo-NgwxhDe7 → WQdcnm9NyqpeNc0Z8_woo}/_buildManifest.js +0 -0
- /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 {
|
|
4
|
-
import { Logo } from "@/components/ui/Logo";
|
|
3
|
+
import { PinKeypad } from "./PinKeypad";
|
|
5
4
|
|
|
6
|
-
// Screen-lock overlay (presence check).
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// the server
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
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
|
-
|
|
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;
|