11.8 KB371 lines
Blame
1"use client";
2
3import { Suspense, useState, useEffect } from "react";
4import { useRouter, useSearchParams } from "next/navigation";
5import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
6import { GroveLogo } from "@/app/components/grove-logo";
7import { auth } from "@/lib/api";
8import { useAuth } from "@/lib/auth";
9
10export default function LoginPage() {
11 return (
12 <Suspense>
13 <LoginPageInner />
14 </Suspense>
15 );
16}
17
18function LoginPageInner() {
19 const [mode, setMode] = useState<"login" | "register">("login");
20 const [username, setUsername] = useState("");
21 const [displayName, setDisplayName] = useState("");
22 const [error, setError] = useState("");
23 const [loading, setLoading] = useState(false);
24 const [pat, setPat] = useState("");
25 const [patLoading, setPatLoading] = useState(false);
26 const { login } = useAuth();
27 const router = useRouter();
28 const searchParams = useSearchParams();
29
30 function redirectAfterLogin() {
31 const redirect = searchParams.get("redirect");
32 if (redirect && redirect.startsWith("/")) {
33 router.push(redirect);
34 } else if (redirect && redirect.startsWith("https://") && new URL(redirect).hostname.endsWith(".grove.host")) {
35 window.location.href = redirect;
36 } else {
37 router.push("/dashboard");
38 }
39 }
40
41 useEffect(() => {
42 document.title = mode === "login" ? "Sign in" : "Create account";
43 }, [mode]);
44
45 async function handleRegister(e: React.FormEvent) {
46 e.preventDefault();
47 setError("");
48 setLoading(true);
49
50 try {
51 const { options } = await auth.registerBegin({
52 username,
53 display_name: displayName || undefined,
54 });
55
56 const attestation = await startRegistration({ optionsJSON: options });
57
58 const result = await auth.registerComplete({
59 response: attestation,
60 challenge: options.challenge,
61 });
62
63 login(result.token, result.user);
64 redirectAfterLogin();
65 } catch (err: unknown) {
66 if (err instanceof Error) {
67 setError(
68 err.name === "NotAllowedError"
69 ? "Passkey creation was cancelled."
70 : err.message
71 );
72 } else {
73 setError("Something went wrong");
74 }
75 } finally {
76 setLoading(false);
77 }
78 }
79
80 async function handleLogin(e: React.FormEvent) {
81 e.preventDefault();
82 setError("");
83 setLoading(true);
84
85 try {
86 const { options } = await auth.loginBegin();
87
88 const assertion = await startAuthentication({ optionsJSON: options });
89
90 const result = await auth.loginComplete({
91 response: assertion,
92 challenge: options.challenge,
93 });
94
95 login(result.token, result.user);
96 redirectAfterLogin();
97 } catch (err: unknown) {
98 if (err instanceof Error) {
99 if (err.name === "NotAllowedError") {
100 setError("No passkey found for this site. Do you need to create an account?");
101 } else if (err.message === "Unknown credential") {
102 setError("Passkey not recognized. Do you need to create an account?");
103 } else {
104 setError(err.message);
105 }
106 } else {
107 setError("Something went wrong");
108 }
109 } finally {
110 setLoading(false);
111 }
112 }
113
114 async function handlePatLogin(e: React.FormEvent) {
115 e.preventDefault();
116 setError("");
117 const trimmed = pat.trim();
118 if (!trimmed) return;
119 if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(trimmed)) {
120 setError("Paste only the raw token value (three dot-separated segments).");
121 return;
122 }
123 setPatLoading(true);
124
125 try {
126 const res = await fetch("/api/auth/me", {
127 headers: { Authorization: `Bearer ${trimmed}` },
128 });
129
130 if (!res.ok) {
131 const body = await res.json().catch(() => ({}));
132 throw new Error(body.error ?? "Invalid or expired token");
133 }
134
135 const { user } = await res.json();
136 login(trimmed, user);
137 redirectAfterLogin();
138 } catch (err: unknown) {
139 setError(err instanceof Error ? err.message : "Failed to verify token");
140 } finally {
141 setPatLoading(false);
142 }
143 }
144
145 return (
146 <div className="min-h-[calc(100vh-41px)] flex items-center justify-center px-4">
147 <div
148 className="w-full max-w-sm p-8"
149 style={{
150 backgroundColor: "var(--bg-card)",
151 border: "1px solid var(--border-subtle)",
152 }}
153 >
154 {/* Logo */}
155 <div className="flex justify-center mb-6">
156 <GroveLogo size={40} />
157 </div>
158
159 <h1 className="text-lg text-center mb-1">
160 {mode === "login" ? "Sign in to Grove" : "Create your account"}
161 </h1>
162 <p
163 className="text-sm text-center mb-6"
164 style={{ color: "var(--text-muted)" }}
165 >
166 {mode === "login"
167 ? "Authenticate with your passkey."
168 : "Choose a username and register a passkey."}
169 </p>
170
171 {mode === "register" ? (
172 <form onSubmit={handleRegister} className="space-y-4">
173 <div>
174 <label
175 className="block text-xs mb-1.5"
176 style={{ color: "var(--text-muted)" }}
177 >
178 Username
179 </label>
180 <input
181 type="text"
182 value={username}
183 onChange={(e) => setUsername(e.target.value)}
184 className="w-full px-3 py-2 text-sm focus:outline-none"
185 style={{
186 backgroundColor: "var(--bg-input)",
187 border: "1px solid var(--border)",
188 color: "var(--text-primary)",
189 }}
190 placeholder="your-username"
191 pattern="[a-zA-Z0-9_-]+"
192 required
193 minLength={2}
194 maxLength={39}
195 autoComplete="username"
196 />
197 <p
198 className="text-xs mt-1.5"
199 style={{ color: "var(--text-faint)" }}
200 >
201 2-39 characters. Letters, numbers, hyphens, underscores.
202 </p>
203 </div>
204
205 <div>
206 <label
207 className="block text-xs mb-1.5"
208 style={{ color: "var(--text-muted)" }}
209 >
210 Display name{" "}
211 <span style={{ color: "var(--text-faint)" }}>(optional)</span>
212 </label>
213 <input
214 type="text"
215 value={displayName}
216 onChange={(e) => setDisplayName(e.target.value)}
217 className="w-full px-3 py-2 text-sm focus:outline-none"
218 style={{
219 backgroundColor: "var(--bg-input)",
220 border: "1px solid var(--border)",
221 color: "var(--text-primary)",
222 }}
223 placeholder="Your Name"
224 />
225 </div>
226
227 {error && (
228 <div
229 className="text-sm px-3 py-2"
230 style={{
231 backgroundColor: "var(--error-bg)",
232 border: "1px solid var(--error-border)",
233 color: "var(--error-text)",
234 }}
235 >
236 {error}
237 </div>
238 )}
239
240 <button
241 type="submit"
242 disabled={loading}
243 className="w-full text-sm py-2"
244 style={{
245 backgroundColor: "var(--accent)",
246 color: "var(--accent-text)",
247 opacity: loading ? 0.6 : 1,
248 cursor: loading ? "wait" : "pointer",
249 }}
250 >
251 {loading ? "Creating account..." : "Create account"}
252 </button>
253 </form>
254 ) : (
255 <form onSubmit={handleLogin}>
256 {error && (
257 <div
258 className="text-sm px-3 py-2 mb-4"
259 style={{
260 backgroundColor: "var(--error-bg)",
261 border: "1px solid var(--error-border)",
262 color: "var(--error-text)",
263 }}
264 >
265 {error}
266 </div>
267 )}
268
269 <button
270 type="submit"
271 disabled={loading}
272 className="w-full text-sm py-2"
273 style={{
274 backgroundColor: "var(--accent)",
275 color: "var(--accent-text)",
276 opacity: loading ? 0.6 : 1,
277 cursor: loading ? "wait" : "pointer",
278 }}
279 >
280 {loading ? "Signing in..." : "Sign in with Passkey"}
281 </button>
282 </form>
283 )}
284
285 {process.env.NODE_ENV !== "production" && (
286 <>
287 <div className="flex items-center gap-3 my-6" style={{ color: "var(--text-faint)" }}>
288 <div className="flex-1 h-px" style={{ backgroundColor: "var(--border-subtle)" }} />
289 <span className="text-xs">or</span>
290 <div className="flex-1 h-px" style={{ backgroundColor: "var(--border-subtle)" }} />
291 </div>
292
293 <form onSubmit={handlePatLogin} className="space-y-3">
294 <label className="block text-xs" style={{ color: "var(--text-muted)" }}>
295 Personal Access Token
296 </label>
297 <input
298 type="password"
299 value={pat}
300 onChange={(e) => setPat(e.target.value)}
301 className="w-full px-3 py-2 text-sm font-mono focus:outline-none"
302 style={{
303 backgroundColor: "var(--bg-input)",
304 border: "1px solid var(--border)",
305 color: "var(--text-primary)",
306 }}
307 placeholder="Paste token from grove.host"
308 autoComplete="off"
309 />
310 <p className="text-xs" style={{ color: "var(--text-faint)" }}>
311 Create a token at{" "}
312 <a
313 href="https://grove.host/dashboard"
314 target="_blank"
315 rel="noopener noreferrer"
316 style={{ color: "var(--accent)" }}
317 >
318 grove.host/dashboard
319 </a>
320 </p>
321 <button
322 type="submit"
323 disabled={patLoading || !pat.trim()}
324 className="w-full text-sm py-2"
325 style={{
326 backgroundColor: "var(--bg-inset)",
327 border: "1px solid var(--border)",
328 color: "var(--text-secondary)",
329 opacity: patLoading || !pat.trim() ? 0.5 : 1,
330 cursor: patLoading ? "wait" : !pat.trim() ? "default" : "pointer",
331 }}
332 >
333 {patLoading ? "Verifying..." : "Sign in with Token"}
334 </button>
335 </form>
336 </>
337 )}
338
339 <div
340 className="mt-6 pt-4 text-sm text-center"
341 style={{ borderTop: "1px solid var(--border-subtle)", color: "var(--text-muted)" }}
342 >
343 {mode === "login" ? (
344 <>
345 No account?{" "}
346 <button
347 onClick={() => { setMode("register"); setError(""); }}
348 style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", font: "inherit", fontSize: "inherit" }}
349 className="hover:underline"
350 >
351 Create one
352 </button>
353 </>
354 ) : (
355 <>
356 Have an account?{" "}
357 <button
358 onClick={() => { setMode("login"); setError(""); }}
359 style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", font: "inherit", fontSize: "inherit" }}
360 className="hover:underline"
361 >
362 Sign in
363 </button>
364 </>
365 )}
366 </div>
367 </div>
368 </div>
369 );
370}
371