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