web/app/login/page.tsxblame
View source
3e3af551"use client";
3e3af552
92d1c583import { Suspense, useState, useEffect } from "react";
3a45fc84import { useRouter, useSearchParams } from "next/navigation";
4a006da5import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
818dc906import { GroveLogo } from "@/app/components/grove-logo";
4a006da7import { auth } from "@/lib/api";
4a006da8import { useAuth } from "@/lib/auth";
3e3af559
3e3af5510export default function LoginPage() {
92d1c5811 return (
92d1c5812 <Suspense>
92d1c5813 <LoginPageInner />
92d1c5814 </Suspense>
92d1c5815 );
92d1c5816}
92d1c5817
92d1c5818function LoginPageInner() {
3e3af5519 const [mode, setMode] = useState<"login" | "register">("login");
3e3af5520 const [username, setUsername] = useState("");
4a006da21 const [displayName, setDisplayName] = useState("");
3e3af5522 const [error, setError] = useState("");
4a006da23 const [loading, setLoading] = useState(false);
bf5fc3324 const [pat, setPat] = useState("");
bf5fc3325 const [patLoading, setPatLoading] = useState(false);
4a006da26 const { login } = useAuth();
4a006da27 const router = useRouter();
3a45fc828 const searchParams = useSearchParams();
3a45fc829
3a45fc830 function redirectAfterLogin() {
3a45fc831 const redirect = searchParams.get("redirect");
6dd74de32 if (redirect && redirect.startsWith("/")) {
6dd74de33 router.push(redirect);
6dd74de34 } else if (redirect && redirect.startsWith("https://") && new URL(redirect).hostname.endsWith(".grove.host")) {
3a45fc835 window.location.href = redirect;
3a45fc836 } else {
3a45fc837 router.push("/dashboard");
3a45fc838 }
3a45fc839 }
3e3af5540
1da987441 useEffect(() => {
1da987442 document.title = mode === "login" ? "Sign in" : "Create account";
1da987443 }, [mode]);
1da987444
4a006da45 async function handleRegister(e: React.FormEvent) {
3e3af5546 e.preventDefault();
3e3af5547 setError("");
4a006da48 setLoading(true);
3e3af5549
3e3af5550 try {
4a006da51 const { options } = await auth.registerBegin({
4a006da52 username,
4a006da53 display_name: displayName || undefined,
3e3af5554 });
3e3af5555
4a006da56 const attestation = await startRegistration({ optionsJSON: options });
4a006da57
4a006da58 const result = await auth.registerComplete({
4a006da59 response: attestation,
4a006da60 challenge: options.challenge,
4a006da61 });
3e3af5562
4a006da63 login(result.token, result.user);
3a45fc864 redirectAfterLogin();
4a006da65 } catch (err: unknown) {
4a006da66 if (err instanceof Error) {
4a006da67 setError(
4a006da68 err.name === "NotAllowedError"
4a006da69 ? "Passkey creation was cancelled."
4a006da70 : err.message
4a006da71 );
4a006da72 } else {
4a006da73 setError("Something went wrong");
3e3af5574 }
4a006da75 } finally {
4a006da76 setLoading(false);
4a006da77 }
4a006da78 }
4a006da79
4a006da80 async function handleLogin(e: React.FormEvent) {
4a006da81 e.preventDefault();
4a006da82 setError("");
4a006da83 setLoading(true);
4a006da84
4a006da85 try {
4a006da86 const { options } = await auth.loginBegin();
4a006da87
4a006da88 const assertion = await startAuthentication({ optionsJSON: options });
3e3af5589
4a006da90 const result = await auth.loginComplete({
4a006da91 response: assertion,
4a006da92 challenge: options.challenge,
4a006da93 });
4a006da94
4a006da95 login(result.token, result.user);
3a45fc896 redirectAfterLogin();
4a006da97 } catch (err: unknown) {
4a006da98 if (err instanceof Error) {
0d3340599 if (err.name === "NotAllowedError") {
0d33405100 setError("No passkey found for this site. Do you need to create an account?");
0d33405101 } else if (err.message === "Unknown credential") {
0d33405102 setError("Passkey not recognized. Do you need to create an account?");
0d33405103 } else {
0d33405104 setError(err.message);
0d33405105 }
4a006da106 } else {
4a006da107 setError("Something went wrong");
4a006da108 }
4a006da109 } finally {
4a006da110 setLoading(false);
3e3af55111 }
3e3af55112 }
3e3af55113
bf5fc33114 async function handlePatLogin(e: React.FormEvent) {
bf5fc33115 e.preventDefault();
bf5fc33116 setError("");
bf5fc33117 const trimmed = pat.trim();
bf5fc33118 if (!trimmed) return;
0fdef14119 if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(trimmed)) {
0fdef14120 setError("Paste only the raw token value (three dot-separated segments).");
0fdef14121 return;
0fdef14122 }
bf5fc33123 setPatLoading(true);
bf5fc33124
bf5fc33125 try {
f0bb192126 const res = await fetch("/api/auth/me", {
bf5fc33127 headers: { Authorization: `Bearer ${trimmed}` },
bf5fc33128 });
bf5fc33129
bf5fc33130 if (!res.ok) {
bf5fc33131 const body = await res.json().catch(() => ({}));
bf5fc33132 throw new Error(body.error ?? "Invalid or expired token");
bf5fc33133 }
bf5fc33134
bf5fc33135 const { user } = await res.json();
bf5fc33136 login(trimmed, user);
3a45fc8137 redirectAfterLogin();
bf5fc33138 } catch (err: unknown) {
bf5fc33139 setError(err instanceof Error ? err.message : "Failed to verify token");
bf5fc33140 } finally {
bf5fc33141 setPatLoading(false);
bf5fc33142 }
bf5fc33143 }
bf5fc33144
3e3af55145 return (
cf89d3c146 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
cf89d3c147 <div
cf89d3c148 className="w-full max-w-sm p-8"
cf89d3c149 style={{
cf89d3c150 backgroundColor: "var(--bg-card)",
cf89d3c151 border: "1px solid var(--border-subtle)",
cf89d3c152 }}
cf89d3c153 >
cf89d3c154 {/* Logo */}
cf89d3c155 <div className="flex justify-center mb-6">
818dc90156 <GroveLogo size={40} />
cf89d3c157 </div>
cf89d3c158
cf89d3c159 <h1 className="text-lg text-center mb-1">
cf89d3c160 {mode === "login" ? "Sign in to Grove" : "Create your account"}
cf89d3c161 </h1>
cf89d3c162 <p
cf89d3c163 className="text-sm text-center mb-6"
cf89d3c164 style={{ color: "var(--text-muted)" }}
cf89d3c165 >
cf89d3c166 {mode === "login"
cf89d3c167 ? "Authenticate with your passkey."
cf89d3c168 : "Choose a username and register a passkey."}
cf89d3c169 </p>
cf89d3c170
cf89d3c171 {mode === "register" ? (
cf89d3c172 <form onSubmit={handleRegister} className="space-y-4">
cf89d3c173 <div>
cf89d3c174 <label
cf89d3c175 className="block text-xs mb-1.5"
cf89d3c176 style={{ color: "var(--text-muted)" }}
cf89d3c177 >
cf89d3c178 Username
cf89d3c179 </label>
cf89d3c180 <input
cf89d3c181 type="text"
cf89d3c182 value={username}
cf89d3c183 onChange={(e) => setUsername(e.target.value)}
cf89d3c184 className="w-full px-3 py-2 text-sm focus:outline-none"
cf89d3c185 style={{
cf89d3c186 backgroundColor: "var(--bg-input)",
cf89d3c187 border: "1px solid var(--border)",
cf89d3c188 color: "var(--text-primary)",
cf89d3c189 }}
cf89d3c190 placeholder="your-username"
cf89d3c191 pattern="[a-zA-Z0-9_-]+"
cf89d3c192 required
cf89d3c193 minLength={2}
cf89d3c194 maxLength={39}
cf89d3c195 autoComplete="username"
cf89d3c196 />
cf89d3c197 <p
cf89d3c198 className="text-xs mt-1.5"
cf89d3c199 style={{ color: "var(--text-faint)" }}
cf89d3c200 >
cf89d3c201 2-39 characters. Letters, numbers, hyphens, underscores.
cf89d3c202 </p>
cf89d3c203 </div>
cf89d3c204
cf89d3c205 <div>
cf89d3c206 <label
cf89d3c207 className="block text-xs mb-1.5"
cf89d3c208 style={{ color: "var(--text-muted)" }}
cf89d3c209 >
cf89d3c210 Display name{" "}
cf89d3c211 <span style={{ color: "var(--text-faint)" }}>(optional)</span>
cf89d3c212 </label>
cf89d3c213 <input
cf89d3c214 type="text"
cf89d3c215 value={displayName}
cf89d3c216 onChange={(e) => setDisplayName(e.target.value)}
cf89d3c217 className="w-full px-3 py-2 text-sm focus:outline-none"
cf89d3c218 style={{
cf89d3c219 backgroundColor: "var(--bg-input)",
cf89d3c220 border: "1px solid var(--border)",
cf89d3c221 color: "var(--text-primary)",
cf89d3c222 }}
cf89d3c223 placeholder="Your Name"
cf89d3c224 />
cf89d3c225 </div>
cf89d3c226
cf89d3c227 {error && (
cf89d3c228 <div
cf89d3c229 className="text-sm px-3 py-2"
cf89d3c230 style={{
cf89d3c231 backgroundColor: "var(--error-bg)",
cf89d3c232 border: "1px solid var(--error-border)",
cf89d3c233 color: "var(--error-text)",
cf89d3c234 }}
cf89d3c235 >
cf89d3c236 {error}
cf89d3c237 </div>
cf89d3c238 )}
135dfe5239
135dfe5240 <button
cf89d3c241 type="submit"
cf89d3c242 disabled={loading}
cf89d3c243 className="w-full text-sm py-2"
cf89d3c244 style={{
cf89d3c245 backgroundColor: "var(--accent)",
cf89d3c246 color: "var(--accent-text)",
cf89d3c247 opacity: loading ? 0.6 : 1,
cf89d3c248 cursor: loading ? "wait" : "pointer",
cf89d3c249 }}
135dfe5250 >
cf89d3c251 {loading ? "Creating account..." : "Create account"}
135dfe5252 </button>
cf89d3c253 </form>
135dfe5254 ) : (
cf89d3c255 <form onSubmit={handleLogin}>
cf89d3c256 {error && (
cf89d3c257 <div
cf89d3c258 className="text-sm px-3 py-2 mb-4"
cf89d3c259 style={{
cf89d3c260 backgroundColor: "var(--error-bg)",
cf89d3c261 border: "1px solid var(--error-border)",
cf89d3c262 color: "var(--error-text)",
cf89d3c263 }}
cf89d3c264 >
cf89d3c265 {error}
cf89d3c266 </div>
cf89d3c267 )}
cf89d3c268
135dfe5269 <button
cf89d3c270 type="submit"
cf89d3c271 disabled={loading}
cf89d3c272 className="w-full text-sm py-2"
cf89d3c273 style={{
cf89d3c274 backgroundColor: "var(--accent)",
cf89d3c275 color: "var(--accent-text)",
cf89d3c276 opacity: loading ? 0.6 : 1,
cf89d3c277 cursor: loading ? "wait" : "pointer",
cf89d3c278 }}
135dfe5279 >
cf89d3c280 {loading ? "Signing in..." : "Sign in with Passkey"}
135dfe5281 </button>
cf89d3c282 </form>
135dfe5283 )}
cf89d3c284
bf5fc33285 {process.env.NODE_ENV !== "production" && (
bf5fc33286 <>
bf5fc33287 <div className="flex items-center gap-3 my-6" style={{ color: "var(--text-faint)" }}>
bf5fc33288 <div className="flex-1 h-px" style={{ backgroundColor: "var(--border-subtle)" }} />
bf5fc33289 <span className="text-xs">or</span>
bf5fc33290 <div className="flex-1 h-px" style={{ backgroundColor: "var(--border-subtle)" }} />
bf5fc33291 </div>
bf5fc33292
bf5fc33293 <form onSubmit={handlePatLogin} className="space-y-3">
bf5fc33294 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
bf5fc33295 Personal Access Token
bf5fc33296 </label>
bf5fc33297 <input
bf5fc33298 type="password"
bf5fc33299 value={pat}
bf5fc33300 onChange={(e) => setPat(e.target.value)}
bf5fc33301 className="w-full px-3 py-2 text-sm font-mono focus:outline-none"
bf5fc33302 style={{
bf5fc33303 backgroundColor: "var(--bg-input)",
bf5fc33304 border: "1px solid var(--border)",
bf5fc33305 color: "var(--text-primary)",
bf5fc33306 }}
bf5fc33307 placeholder="Paste token from grove.host"
bf5fc33308 autoComplete="off"
bf5fc33309 />
bf5fc33310 <p className="text-xs" style={{ color: "var(--text-faint)" }}>
bf5fc33311 Create a token at{" "}
bf5fc33312 <a
bf5fc33313 href="https://grove.host/dashboard"
bf5fc33314 target="_blank"
bf5fc33315 rel="noopener noreferrer"
bf5fc33316 style={{ color: "var(--accent)" }}
bf5fc33317 >
bf5fc33318 grove.host/dashboard
bf5fc33319 </a>
bf5fc33320 </p>
bf5fc33321 <button
bf5fc33322 type="submit"
bf5fc33323 disabled={patLoading || !pat.trim()}
bf5fc33324 className="w-full text-sm py-2"
bf5fc33325 style={{
bf5fc33326 backgroundColor: "var(--bg-inset)",
bf5fc33327 border: "1px solid var(--border)",
bf5fc33328 color: "var(--text-secondary)",
bf5fc33329 opacity: patLoading || !pat.trim() ? 0.5 : 1,
bf5fc33330 cursor: patLoading ? "wait" : !pat.trim() ? "default" : "pointer",
bf5fc33331 }}
bf5fc33332 >
bf5fc33333 {patLoading ? "Verifying..." : "Sign in with Token"}
bf5fc33334 </button>
bf5fc33335 </form>
bf5fc33336 </>
bf5fc33337 )}
bf5fc33338
cf89d3c339 <div
cf89d3c340 className="mt-6 pt-4 text-sm text-center"
cf89d3c341 style={{ borderTop: "1px solid var(--border-subtle)", color: "var(--text-muted)" }}
cf89d3c342 >
cf89d3c343 {mode === "login" ? (
cf89d3c344 <>
cf89d3c345 No account?{" "}
cf89d3c346 <button
cf89d3c347 onClick={() => { setMode("register"); setError(""); }}
cf89d3c348 style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", font: "inherit", fontSize: "inherit" }}
cf89d3c349 className="hover:underline"
cf89d3c350 >
cf89d3c351 Create one
cf89d3c352 </button>
cf89d3c353 </>
cf89d3c354 ) : (
cf89d3c355 <>
cf89d3c356 Have an account?{" "}
cf89d3c357 <button
cf89d3c358 onClick={() => { setMode("login"); setError(""); }}
cf89d3c359 style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", font: "inherit", fontSize: "inherit" }}
cf89d3c360 className="hover:underline"
cf89d3c361 >
cf89d3c362 Sign in
cf89d3c363 </button>
cf89d3c364 </>
cf89d3c365 )}
cf89d3c366 </div>
3e3af55367 </div>
3e3af55368 </div>
3e3af55369 );
3e3af55370}