web/app/cli-auth/page.tsxblame
View source
e93a9781"use client";
e93a9782
e93a9783import { Suspense, useState, useEffect } from "react";
e93a9784import { useSearchParams } from "next/navigation";
e93a9785import { startAuthentication } from "@simplewebauthn/browser";
818dc906import { GroveLogo } from "@/app/components/grove-logo";
e93a9787import { auth } from "@/lib/api";
bf5fc338import { Skeleton } from "@/app/components/skeleton";
e93a9789
7010ba910function DeviceCodeAuth({ code }: { code: string }) {
7010ba911 const [status, setStatus] = useState<"ready" | "confirm" | "loading" | "success" | "error">("ready");
7010ba912 const [error, setError] = useState("");
7010ba913 const [username, setUsername] = useState<string | null>(null);
7010ba914
7010ba915 useEffect(() => {
7010ba916 const token = localStorage.getItem("grove_hub_token");
7010ba917 if (token) {
7010ba918 try {
7010ba919 const payload = JSON.parse(atob(token.split(".")[1]));
7010ba920 setUsername(payload.username);
7010ba921 } catch { /* ignore */ }
7010ba922 setStatus("confirm");
7010ba923 }
7010ba924 }, []);
7010ba925
7010ba926 async function approveWithToken(sessionToken: string) {
7010ba927 setStatus("loading");
7010ba928 try {
1d4080029 const res = await fetch(`/api/auth/device-code/${code}/approve`, {
7010ba930 method: "POST",
7010ba931 headers: {
7010ba932 "Content-Type": "application/json",
7010ba933 Authorization: `Bearer ${sessionToken}`,
7010ba934 },
7010ba935 });
7010ba936 if (!res.ok) {
7010ba937 const data = await res.json().catch(() => ({}));
7010ba938 throw new Error((data as any).error || "Failed to approve");
7010ba939 }
7010ba940 setStatus("success");
7010ba941 } catch (err: any) {
7010ba942 setStatus("error");
7010ba943 setError(err.message || "Failed to approve device code");
7010ba944 }
7010ba945 }
7010ba946
7010ba947 async function handleAuthorize() {
7010ba948 const token = localStorage.getItem("grove_hub_token");
7010ba949 if (token) await approveWithToken(token);
7010ba950 }
7010ba951
7010ba952 async function handleLogin() {
7010ba953 setError("");
7010ba954 setStatus("loading");
7010ba955 try {
7010ba956 const { options } = await auth.loginBegin();
7010ba957 const assertion = await startAuthentication({ optionsJSON: options });
7010ba958 const result = await auth.loginComplete({
7010ba959 response: assertion,
7010ba960 challenge: options.challenge,
7010ba961 });
7010ba962 await approveWithToken(result.token);
7010ba963 } catch (err: any) {
7010ba964 setStatus("error");
7010ba965 if (err.name === "NotAllowedError") {
7010ba966 setError("Passkey authentication was cancelled.");
7010ba967 } else if (err.message === "Unknown credential") {
7010ba968 setError("Passkey not recognized. Do you have an account?");
7010ba969 } else {
7010ba970 setError(err.message || "Authentication failed");
7010ba971 }
7010ba972 }
7010ba973 }
7010ba974
7010ba975 return (
7010ba976 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
7010ba977 <div
7010ba978 className="w-full max-w-sm p-8"
7010ba979 style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-subtle)" }}
7010ba980 >
7010ba981 <div className="flex justify-center mb-6">
7010ba982 <GroveLogo size={40} />
7010ba983 </div>
7010ba984 <h1 className="text-lg text-center mb-1">Authorize Grove CLI</h1>
7010ba985
7010ba986 {status === "success" ? (
7010ba987 <div className="text-center mt-6">
7010ba988 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
7010ba989 CLI authorized! You can close this tab and return to the terminal.
7010ba990 </p>
7010ba991 </div>
7010ba992 ) : status === "confirm" ? (
7010ba993 <>
7010ba994 <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}>
7010ba995 Authorize the CLI as{" "}
7010ba996 <strong style={{ color: "var(--text-primary)" }}>{username}</strong>?
7010ba997 </p>
7010ba998 <div
7010ba999 className="text-center text-lg font-mono tracking-widest mb-6 py-3"
7010ba9100 style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }}
7010ba9101 >
7010ba9102 {code}
7010ba9103 </div>
7010ba9104 {error && (
7010ba9105 <div
7010ba9106 className="text-sm px-3 py-2 mb-4"
7010ba9107 style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }}
7010ba9108 >
7010ba9109 {error}
7010ba9110 </div>
7010ba9111 )}
7010ba9112 <button
7010ba9113 onClick={handleAuthorize}
7010ba9114 className="w-full text-sm py-2"
7010ba9115 style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
7010ba9116 >
7010ba9117 Authorize CLI
7010ba9118 </button>
7010ba9119 </>
7010ba9120 ) : (
7010ba9121 <>
7010ba9122 <p className="text-sm text-center mb-4" style={{ color: "var(--text-muted)" }}>
7010ba9123 Confirm the code matches what&apos;s shown in your terminal.
7010ba9124 </p>
7010ba9125 <div
7010ba9126 className="text-center text-lg font-mono tracking-widest mb-6 py-3"
7010ba9127 style={{ backgroundColor: "var(--bg-page)", border: "1px solid var(--border-subtle)" }}
7010ba9128 >
7010ba9129 {code}
7010ba9130 </div>
7010ba9131 {error && (
7010ba9132 <div
7010ba9133 className="text-sm px-3 py-2 mb-4"
7010ba9134 style={{ backgroundColor: "var(--error-bg)", border: "1px solid var(--error-border)", color: "var(--error-text)" }}
7010ba9135 >
7010ba9136 {error}
7010ba9137 </div>
7010ba9138 )}
7010ba9139 <button
7010ba9140 onClick={handleLogin}
7010ba9141 disabled={status === "loading"}
7010ba9142 className="w-full text-sm py-2"
7010ba9143 style={{
7010ba9144 backgroundColor: "var(--accent)",
7010ba9145 color: "var(--accent-text)",
7010ba9146 opacity: status === "loading" ? 0.6 : 1,
7010ba9147 cursor: status === "loading" ? "wait" : "pointer",
7010ba9148 }}
7010ba9149 >
7010ba9150 {status === "loading" ? "Authenticating..." : "Sign in with Passkey"}
7010ba9151 </button>
7010ba9152 </>
7010ba9153 )}
7010ba9154 </div>
7010ba9155 </div>
7010ba9156 );
7010ba9157}
7010ba9158
e93a978159function CliAuthInner() {
e93a978160 const searchParams = useSearchParams();
e93a978161 const callback = searchParams.get("callback");
7010ba9162 const code = searchParams.get("code");
ff43545163 const [status, setStatus] = useState<
ff43545164 "ready" | "confirm" | "loading" | "success" | "error"
ff43545165 >("ready");
e93a978166 const [error, setError] = useState("");
ff43545167 const [username, setUsername] = useState<string | null>(null);
e93a978168
7010ba9169 // Device code flow
7010ba9170 if (code) {
7010ba9171 return <DeviceCodeAuth code={code} />;
7010ba9172 }
7010ba9173
e93a978174 // Validate callback URL is localhost
e93a978175 const isValidCallback = (() => {
e93a978176 if (!callback) return false;
e93a978177 try {
e93a978178 const url = new URL(callback);
e93a978179 return url.hostname === "localhost" || url.hostname === "127.0.0.1";
e93a978180 } catch {
e93a978181 return false;
e93a978182 }
e93a978183 })();
e93a978184
e93a978185 useEffect(() => {
e93a978186 if (!isValidCallback) return;
e93a978187
ff43545188 // Check if user is already logged in
e93a978189 const token = localStorage.getItem("grove_hub_token");
e93a978190 if (token) {
ff43545191 // Decode username from JWT for the confirmation screen
ff43545192 try {
ff43545193 const payload = JSON.parse(atob(token.split(".")[1]));
ff43545194 setUsername(payload.username);
ff43545195 } catch {
ff43545196 // ignore
ff43545197 }
ff43545198 setStatus("confirm");
e93a978199 }
e93a978200 }, []);
e93a978201
e93a978202 async function createPatAndRedirect(sessionToken: string) {
e93a978203 setStatus("loading");
e93a978204 try {
e93a978205 const existingToken = localStorage.getItem("grove_hub_token");
e93a978206 localStorage.setItem("grove_hub_token", sessionToken);
e93a978207
e93a978208 const result = await auth.createToken({
e93a978209 name: `CLI (${new Date().toLocaleDateString()})`,
e93a978210 expires_in: "1y",
e93a978211 });
e93a978212
e93a978213 if (existingToken) {
e93a978214 localStorage.setItem("grove_hub_token", existingToken);
e93a978215 } else {
e93a978216 localStorage.removeItem("grove_hub_token");
e93a978217 }
e93a978218
e93a978219 setStatus("success");
e93a978220 window.location.href = `${callback}?token=${encodeURIComponent(result.token)}`;
e93a978221 } catch (err: any) {
e93a978222 setStatus("error");
e93a978223 setError(err.message || "Failed to create access token");
e93a978224 }
e93a978225 }
e93a978226
ff43545227 async function handleAuthorize() {
ff43545228 const token = localStorage.getItem("grove_hub_token");
ff43545229 if (token) {
ff43545230 await createPatAndRedirect(token);
ff43545231 }
ff43545232 }
ff43545233
e93a978234 async function handleLogin() {
e93a978235 setError("");
e93a978236 setStatus("loading");
e93a978237
e93a978238 try {
e93a978239 const { options } = await auth.loginBegin();
e93a978240 const assertion = await startAuthentication({ optionsJSON: options });
e93a978241 const result = await auth.loginComplete({
e93a978242 response: assertion,
e93a978243 challenge: options.challenge,
e93a978244 });
e93a978245
e93a978246 await createPatAndRedirect(result.token);
e93a978247 } catch (err: any) {
e93a978248 setStatus("error");
e93a978249 if (err.name === "NotAllowedError") {
e93a978250 setError("Passkey authentication was cancelled.");
e93a978251 } else if (err.message === "Unknown credential") {
e93a978252 setError("Passkey not recognized. Do you have an account?");
e93a978253 } else {
e93a978254 setError(err.message || "Authentication failed");
e93a978255 }
e93a978256 }
e93a978257 }
e93a978258
e93a978259 if (!callback || !isValidCallback) {
e93a978260 return (
e93a978261 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
e93a978262 <div
e93a978263 className="w-full max-w-sm p-8 text-center"
e93a978264 style={{
e93a978265 backgroundColor: "var(--bg-card)",
e93a978266 border: "1px solid var(--border-subtle)",
e93a978267 }}
e93a978268 >
e93a978269 <h1 className="text-lg mb-2">Invalid Request</h1>
e93a978270 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
e93a978271 This page is used by the Grove CLI to authenticate.
e93a978272 {!callback && " No callback URL provided."}
e93a978273 {callback && !isValidCallback && " Callback must be localhost."}
e93a978274 </p>
e93a978275 </div>
e93a978276 </div>
e93a978277 );
e93a978278 }
e93a978279
e93a978280 return (
e93a978281 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
e93a978282 <div
e93a978283 className="w-full max-w-sm p-8"
e93a978284 style={{
e93a978285 backgroundColor: "var(--bg-card)",
e93a978286 border: "1px solid var(--border-subtle)",
e93a978287 }}
e93a978288 >
e93a978289 {/* Logo */}
e93a978290 <div className="flex justify-center mb-6">
818dc90291 <GroveLogo size={40} />
e93a978292 </div>
e93a978293
e93a978294 <h1 className="text-lg text-center mb-1">Authorize Grove CLI</h1>
e93a978295
e93a978296 {status === "success" ? (
ff43545297 <div className="text-center mt-6">
e93a978298 <p className="text-sm" style={{ color: "var(--text-muted)" }}>
e93a978299 Authenticated! Redirecting back to CLI...
e93a978300 </p>
e93a978301 </div>
ff43545302 ) : status === "confirm" ? (
ff43545303 <>
ff43545304 <p
ff43545305 className="text-sm text-center mb-6"
ff43545306 style={{ color: "var(--text-muted)" }}
ff43545307 >
ff43545308 The Grove CLI is requesting access to your account
ff43545309 {username ? (
ff43545310 <>
ff43545311 {" "}as <strong style={{ color: "var(--text-primary)" }}>{username}</strong>
ff43545312 </>
ff43545313 ) : (
ff43545314 ""
ff43545315 )}
ff43545316 . This will create a personal access token.
ff43545317 </p>
ff43545318
ff43545319 {error && (
ff43545320 <div
ff43545321 className="text-sm px-3 py-2 mb-4"
ff43545322 style={{
ff43545323 backgroundColor: "var(--error-bg)",
ff43545324 border: "1px solid var(--error-border)",
ff43545325 color: "var(--error-text)",
ff43545326 }}
ff43545327 >
ff43545328 {error}
ff43545329 </div>
ff43545330 )}
ff43545331
ff43545332 <button
ff43545333 onClick={handleAuthorize}
ff43545334 disabled={status === "loading" as any}
ff43545335 className="w-full text-sm py-2"
ff43545336 style={{
ff43545337 backgroundColor: "var(--accent)",
ff43545338 color: "var(--accent-text)",
ff43545339 }}
ff43545340 >
ff43545341 Authorize CLI
ff43545342 </button>
ff43545343 </>
e93a978344 ) : (
e93a978345 <>
ff43545346 <p
ff43545347 className="text-sm text-center mb-6"
ff43545348 style={{ color: "var(--text-muted)" }}
ff43545349 >
ff43545350 Sign in with your passkey to authorize the CLI.
ff43545351 </p>
ff43545352
e93a978353 {error && (
e93a978354 <div
e93a978355 className="text-sm px-3 py-2 mb-4"
e93a978356 style={{
e93a978357 backgroundColor: "var(--error-bg)",
e93a978358 border: "1px solid var(--error-border)",
e93a978359 color: "var(--error-text)",
e93a978360 }}
e93a978361 >
e93a978362 {error}
e93a978363 </div>
e93a978364 )}
e93a978365
e93a978366 <button
e93a978367 onClick={handleLogin}
e93a978368 disabled={status === "loading"}
e93a978369 className="w-full text-sm py-2"
e93a978370 style={{
e93a978371 backgroundColor: "var(--accent)",
e93a978372 color: "var(--accent-text)",
e93a978373 opacity: status === "loading" ? 0.6 : 1,
e93a978374 cursor: status === "loading" ? "wait" : "pointer",
e93a978375 }}
e93a978376 >
e93a978377 {status === "loading"
e93a978378 ? "Authenticating..."
e93a978379 : "Sign in with Passkey"}
e93a978380 </button>
e93a978381 </>
e93a978382 )}
e93a978383 </div>
e93a978384 </div>
e93a978385 );
e93a978386}
e93a978387
e93a978388export default function CliAuthPage() {
e93a978389 return (
e93a978390 <Suspense
e93a978391 fallback={
bf5fc33392 <div className="min-h-[calc(100vh-56px)] flex items-center justify-center">
bf5fc33393 <div className="w-full max-w-sm space-y-4 p-6">
bf5fc33394 <Skeleton width="40px" height="40px" className="mx-auto" />
bf5fc33395 <Skeleton width="160px" height="1.25rem" className="mx-auto" />
bf5fc33396 <Skeleton width="100%" height="2.5rem" />
bf5fc33397 </div>
e93a978398 </div>
e93a978399 }
e93a978400 >
e93a978401 <CliAuthInner />
e93a978402 </Suspense>
e93a978403 );
e93a978404}