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