12.6 KB405 lines
Blame
1"use client";
2
3import { Suspense, useState, useEffect } from "react";
4import { useSearchParams } from "next/navigation";
5import { startAuthentication } from "@simplewebauthn/browser";
6import { GroveLogo } from "@/app/components/grove-logo";
7import { auth } from "@/lib/api";
8import { Skeleton } from "@/app/components/skeleton";
9
10function DeviceCodeAuth({ code }: { code: string }) {
11 const [status, setStatus] = useState<"ready" | "confirm" | "loading" | "success" | "error">("ready");
12 const [error, setError] = useState("");
13 const [username, setUsername] = useState<string | null>(null);
14
15 useEffect(() => {
16 const token = localStorage.getItem("grove_hub_token");
17 if (token) {
18 try {
19 const payload = JSON.parse(atob(token.split(".")[1]));
20 setUsername(payload.username);
21 } catch { /* ignore */ }
22 setStatus("confirm");
23 }
24 }, []);
25
26 async function approveWithToken(sessionToken: string) {
27 setStatus("loading");
28 try {
29 const res = await fetch(`/api/auth/device-code/${code}/approve`, {
30 method: "POST",
31 headers: {
32 "Content-Type": "application/json",
33 Authorization: `Bearer ${sessionToken}`,
34 },
35 });
36 if (!res.ok) {
37 const data = await res.json().catch(() => ({}));
38 throw new Error((data as any).error || "Failed to approve");
39 }
40 setStatus("success");
41 } catch (err: any) {
42 setStatus("error");
43 setError(err.message || "Failed to approve device code");
44 }
45 }
46
47 async function handleAuthorize() {
48 const token = localStorage.getItem("grove_hub_token");
49 if (token) await approveWithToken(token);
50 }
51
52 async function handleLogin() {
53 setError("");
54 setStatus("loading");
55 try {
56 const { options } = await auth.loginBegin();
57 const assertion = await startAuthentication({ optionsJSON: options });
58 const result = await auth.loginComplete({
59 response: assertion,
60 challenge: options.challenge,
61 });
62 await approveWithToken(result.token);
63 } catch (err: any) {
64 setStatus("error");
65 if (err.name === "NotAllowedError") {
66 setError("Passkey authentication was cancelled.");
67 } else if (err.message === "Unknown credential") {
68 setError("Passkey not recognized. Do you have an account?");
69 } else {
70 setError(err.message || "Authentication failed");
71 }
72 }
73 }
74
75 return (
76 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
77 <div
78 className="w-full max-w-sm p-8"
79 style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-subtle)" }}
80 >
81 <div className="flex justify-center mb-6">
82 <GroveLogo size={40} />
83 </div>
84 <h1 className="text-lg text-center mb-1">Authorize Grove CLI</h1>
85
86 {status === "success" ? (
87 <div className="text-center mt-6">
88 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
89 CLI authorized! You can close this tab and return to the terminal.
90 </p>
91 </div>
92 ) : status === "confirm" ? (
93 <>
94 <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}>
95 Authorize the CLI as{" "}
96 <strong style={{ color: "var(--text-primary)" }}>{username}</strong>?
97 </p>
98 <div
99 className="text-center text-lg font-mono tracking-widest mb-6 py-3"
100 style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }}
101 >
102 {code}
103 </div>
104 {error && (
105 <div
106 className="text-sm px-3 py-2 mb-4"
107 style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }}
108 >
109 {error}
110 </div>
111 )}
112 <button
113 onClick={handleAuthorize}
114 className="w-full text-sm py-2"
115 style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
116 >
117 Authorize CLI
118 </button>
119 </>
120 ) : (
121 <>
122 <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}>
123 Confirm the code matches what&apos;s shown in your terminal.
124 </p>
125 <div
126 className="text-center text-lg font-mono tracking-widest mb-6 py-3"
127 style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }}
128 >
129 {code}
130 </div>
131 {error && (
132 <div
133 className="text-sm px-3 py-2 mb-4"
134 style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }}
135 >
136 {error}
137 </div>
138 )}
139 <button
140 onClick={handleLogin}
141 disabled={status === "loading"}
142 className="w-full text-sm py-2"
143 style={{
144 backgroundColor: "var(--accent)",
145 color: "var(--accent-text)",
146 opacity: status === "loading" ? 0.6 : 1,
147 cursor: status === "loading" ? "wait" : "pointer",
148 }}
149 >
150 {status === "loading" ? "Authenticating..." : "Sign in with Passkey"}
151 </button>
152 </>
153 )}
154 </div>
155 </div>
156 );
157}
158
159function CliAuthInner() {
160 const searchParams = useSearchParams();
161 const callback = searchParams.get("callback");
162 const code = searchParams.get("code");
163 const [status, setStatus] = useState<
164 "ready" | "confirm" | "loading" | "success" | "error"
165 >("ready");
166 const [error, setError] = useState("");
167 const [username, setUsername] = useState<string | null>(null);
168
169 // Device code flow
170 if (code) {
171 return <DeviceCodeAuth code={code} />;
172 }
173
174 // Validate callback URL is localhost
175 const isValidCallback = (() => {
176 if (!callback) return false;
177 try {
178 const url = new URL(callback);
179 return url.hostname === "localhost" || url.hostname === "127.0.0.1";
180 } catch {
181 return false;
182 }
183 })();
184
185 useEffect(() => {
186 if (!isValidCallback) return;
187
188 // Check if user is already logged in
189 const token = localStorage.getItem("grove_hub_token");
190 if (token) {
191 // Decode username from JWT for the confirmation screen
192 try {
193 const payload = JSON.parse(atob(token.split(".")[1]));
194 setUsername(payload.username);
195 } catch {
196 // ignore
197 }
198 setStatus("confirm");
199 }
200 }, []);
201
202 async function createPatAndRedirect(sessionToken: string) {
203 setStatus("loading");
204 try {
205 const existingToken = localStorage.getItem("grove_hub_token");
206 localStorage.setItem("grove_hub_token", sessionToken);
207
208 const result = await auth.createToken({
209 name: `CLI (${new Date().toLocaleDateString()})`,
210 expires_in: "1y",
211 });
212
213 if (existingToken) {
214 localStorage.setItem("grove_hub_token", existingToken);
215 } else {
216 localStorage.removeItem("grove_hub_token");
217 }
218
219 setStatus("success");
220 window.location.href = `${callback}?token=${encodeURIComponent(result.token)}`;
221 } catch (err: any) {
222 setStatus("error");
223 setError(err.message || "Failed to create access token");
224 }
225 }
226
227 async function handleAuthorize() {
228 const token = localStorage.getItem("grove_hub_token");
229 if (token) {
230 await createPatAndRedirect(token);
231 }
232 }
233
234 async function handleLogin() {
235 setError("");
236 setStatus("loading");
237
238 try {
239 const { options } = await auth.loginBegin();
240 const assertion = await startAuthentication({ optionsJSON: options });
241 const result = await auth.loginComplete({
242 response: assertion,
243 challenge: options.challenge,
244 });
245
246 await createPatAndRedirect(result.token);
247 } catch (err: any) {
248 setStatus("error");
249 if (err.name === "NotAllowedError") {
250 setError("Passkey authentication was cancelled.");
251 } else if (err.message === "Unknown credential") {
252 setError("Passkey not recognized. Do you have an account?");
253 } else {
254 setError(err.message || "Authentication failed");
255 }
256 }
257 }
258
259 if (!callback || !isValidCallback) {
260 return (
261 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
262 <div
263 className="w-full max-w-sm p-8 text-center"
264 style={{
265 backgroundColor: "var(--bg-card)",
266 border: "1px solid var(--border-subtle)",
267 }}
268 >
269 <h1 className="text-lg mb-2">Invalid Request</h1>
270 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
271 This page is used by the Grove CLI to authenticate.
272 {!callback && " No callback URL provided."}
273 {callback && !isValidCallback && " Callback must be localhost."}
274 </p>
275 </div>
276 </div>
277 );
278 }
279
280 return (
281 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
282 <div
283 className="w-full max-w-sm p-8"
284 style={{
285 backgroundColor: "var(--bg-card)",
286 border: "1px solid var(--border-subtle)",
287 }}
288 >
289 {/* Logo */}
290 <div className="flex justify-center mb-6">
291 <GroveLogo size={40} />
292 </div>
293
294 <h1 className="text-lg text-center mb-1">Authorize Grove CLI</h1>
295
296 {status === "success" ? (
297 <div className="text-center mt-6">
298 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
299 Authenticated! Redirecting back to CLI...
300 </p>
301 </div>
302 ) : status === "confirm" ? (
303 <>
304 <p
305 className="text-sm text-center mb-6"
306 style={{ color: "var(--text-muted)" }}
307 >
308 The Grove CLI is requesting access to your account
309 {username ? (
310 <>
311 {" "}as <strong style={{ color: "var(--text-primary)" }}>{username}</strong>
312 </>
313 ) : (
314 ""
315 )}
316 . This will create a personal access token.
317 </p>
318
319 {error && (
320 <div
321 className="text-sm px-3 py-2 mb-4"
322 style={{
323 backgroundColor: "var(--error-bg)",
324 border: "1px solid var(--error-border)",
325 color: "var(--error-text)",
326 }}
327 >
328 {error}
329 </div>
330 )}
331
332 <button
333 onClick={handleAuthorize}
334 disabled={status === "loading" as any}
335 className="w-full text-sm py-2"
336 style={{
337 backgroundColor: "var(--accent)",
338 color: "var(--accent-text)",
339 }}
340 >
341 Authorize CLI
342 </button>
343 </>
344 ) : (
345 <>
346 <p
347 className="text-sm text-center mb-6"
348 style={{ color: "var(--text-muted)" }}
349 >
350 Sign in with your passkey to authorize the CLI.
351 </p>
352
353 {error && (
354 <div
355 className="text-sm px-3 py-2 mb-4"
356 style={{
357 backgroundColor: "var(--error-bg)",
358 border: "1px solid var(--error-border)",
359 color: "var(--error-text)",
360 }}
361 >
362 {error}
363 </div>
364 )}
365
366 <button
367 onClick={handleLogin}
368 disabled={status === "loading"}
369 className="w-full text-sm py-2"
370 style={{
371 backgroundColor: "var(--accent)",
372 color: "var(--accent-text)",
373 opacity: status === "loading" ? 0.6 : 1,
374 cursor: status === "loading" ? "wait" : "pointer",
375 }}
376 >
377 {status === "loading"
378 ? "Authenticating..."
379 : "Sign in with Passkey"}
380 </button>
381 </>
382 )}
383 </div>
384 </div>
385 );
386}
387
388export default function CliAuthPage() {
389 return (
390 <Suspense
391 fallback={
392 <div className="min-h-[calc(100vh-56px)] flex items-center justify-center">
393 <div className="w-full max-w-sm space-y-4 p-6">
394 <Skeleton width="40px" height="40px" className="mx-auto" />
395 <Skeleton width="160px" height="1.25rem" className="mx-auto" />
396 <Skeleton width="100%" height="2.5rem" />
397 </div>
398 </div>
399 }
400 >
401 <CliAuthInner />
402 </Suspense>
403 );
404}
405