1"use client";
2
3import { Suspense, useEffect, useState } from "react";
4import { useRouter, useSearchParams } from "next/navigation";
5import Link from "next/link";
6import { useForm } from "react-hook-form";
7import { zodResolver } from "@hookform/resolvers/zod";
8import { z } from "zod";
9import { authApi, isApiError } from "@/lib/api";
10import { API_BASE } from "@/lib/constants";
11import { authStore, useAuthStore } from "@/lib/auth-store";
12import { toast } from "@/stores/ui-store";
13import { Field, FormError, Input, SubmitButton } from "@/components/ui/form";
14
15
21const API_ORIGIN = (() => {
22 try {
23 return new URL(API_BASE).origin;
24 } catch {
25 return "";
26 }
27})();
28
29
36function resolveNext(next: string): string {
37 if (!next) return "/w/default";
38 if (next.startsWith("/")) return next;
39 if (API_ORIGIN && next.startsWith(API_ORIGIN)) return next;
40 return "/w/default";
41}
42
43const loginSchema = z.object({
44 email: z.string().email("Enter a valid email address"),
45 password: z.string().min(1, "Password is required")
46});
47type LoginValues = z.infer<typeof loginSchema>;
48
49function LoginPage() {
50 const router = useRouter();
51 const search = useSearchParams();
52 const user = useAuthStore((s) => s.user);
53 const next = search.get("next") ?? "";
54
55
56
57
58 const ssoError = search.get("error") ?? "";
59 const ssoOrgSlug = search.get("org") ?? "";
60 const ssoErrorBanner = ssoError.startsWith("sso_") ? ssoError : "";
61 const ssoErrorText = (() => {
62 const t = ssoError.replace(/^sso_(oidc_)?/, "");
63 const map: Record<string, string> = {
64 bad_signature: "Your identity provider's response was not validly signed.",
65 expired: "The sign-in from your identity provider has expired.",
66 replayed: "That sign-in response was already used.",
67 audience_mismatch: "The sign-in response was addressed to the wrong service.",
68 destination_mismatch: "The sign-in response was for the wrong destination.",
69 issuer_mismatch: "The sign-in response came from an unexpected identity provider.",
70 status_not_success: "Your identity provider reported an error.",
71 missing_nameid: "Your identity provider did not return an identifier.",
72 cert_mismatch: "The identity provider's certificate no longer matches.",
73 unknown_org: "We could not find the organization for that sign-in.",
74 no_sso_provider: "This organization no longer has single sign-on configured.",
75 suspended: "Your account is suspended.",
76 unknown_email: "Your email is not recognized and provisioning is disabled.",
77 jit_disabled: "Account provisioning is disabled for this organization.",
78 sso_required: "This organization requires SSO sign-in.",
79 state_invalid: "The SSO session state is invalid or expired — try again.",
80 state_replayed: "That SSO response was already used.",
81 missing_code: "Your identity provider did not return an authorization code.",
82 token_exchange_failed: "We could not exchange the SSO code for a token.",
83 invalid_id_token: "The SSO token could not be verified.",
84 unsupported_alg: "The SSO token uses an unsupported signature algorithm.",
85 unknown_key: "The SSO token was signed with an unknown key.",
86 invalid_signature: "The SSO token signature is invalid.",
87 not_yet_valid: "The SSO token is not valid yet.",
88 nonce_mismatch: "The SSO token does not match this sign-in attempt.",
89 missing_claims: "Your identity provider did not return the required claims.",
90 sso_misconfigured: "Single sign-on is not fully configured for this organization."
91 };
92 return (
93 map[t] ??
94 "Single sign-on did not complete. Please try again, or contact your organization administrator."
95 );
96 })();
97 const [serverError, setServerError] = useState<string | null>(null);
98
99
100 useEffect(() => {
101 if (user) {
102 router.replace(resolveNext(next));
103 }
104 }, [user, next, router]);
105
106 const {
107 register,
108 handleSubmit,
109 watch,
110 formState: { errors, isSubmitting }
111 } = useForm<LoginValues>({
112 resolver: zodResolver(loginSchema),
113 defaultValues: { email: "", password: "" }
114 });
115
116
117
118
119
120
121 const email = watch("email") ?? "";
122 const [ssoOrgs, setSsoOrgs] = useState<Array<{ slug: string; ssoAvailable: boolean; startUrl: string }>>([]);
123 useEffect(() => {
124 const looksLikeEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
125 if (!looksLikeEmail) {
126 setSsoOrgs([]);
127 return;
128 }
129 let cancelled = false;
130 const timer = setTimeout(async () => {
131 try {
132 const res = await authApi.ssoOptions(email.trim());
133 if (!cancelled) setSsoOrgs(res.orgs ?? []);
134 } catch {
135
136 if (!cancelled) setSsoOrgs([]);
137 }
138 }, 350);
139 return () => {
140 cancelled = true;
141 clearTimeout(timer);
142 };
143 }, [email]);
144
145
146 const startSso = (startUrl: string) => {
147 window.location.assign(startUrl);
148 };
149
150
151 const ssoStartFor = (slug: string): string => {
152 const known = ssoOrgs.find((o) => o.slug === slug);
153 return known?.startUrl ?? `${API_ORIGIN}/sso/start/${slug}`;
154 };
155
156
157
158 const [ssoRequired, setSsoRequired] = useState<{ orgs: string[]; startUrl?: string } | null>(null);
159
160 const onSubmit = async (values: LoginValues) => {
161 setServerError(null);
162 setSsoRequired(null);
163 try {
164
165
166
167 const result = await authApi.login(values);
168 authStore.setUser(result.user, result.accessToken);
169 toast("success", "Welcome back", result.user.name ? `Signed in as ${result.user.name}` : undefined);
170 router.replace(resolveNext(next));
171 } catch (err) {
172
173
174
175 if (isApiError(err) && err.status === 403) {
176 const details = Array.isArray(err.details) ? (err.details as Array<Record<string, unknown>>) : [];
177 const ssoDetail = details.find((d) => d && d.reason === "sso_required");
178 if (ssoDetail) {
179 const orgs = (ssoDetail.orgs as string[]) ?? [];
180
181
182
183 const known = ssoOrgs.find((o) => orgs.includes(o.slug));
184 setSsoRequired({
185 orgs,
186 startUrl: known?.startUrl ?? (orgs[0] ? ssoStartFor(orgs[0]) : undefined)
187 });
188 return;
189 }
190 }
191 const message = err instanceof Error ? err.message : "Sign in failed. Please try again.";
192 setServerError(message);
193 }
194 };
195
196 return (
197 <div className="space-y-5">
198 <div>
199 <h1 className="text-xl font-semibold">Sign in</h1>
200 <p className="mt-1 text-sm text-muted-foreground">Enter your workspace credentials.</p>
201 </div>
202
203 {ssoErrorBanner && (
204 <div
205 role="alert"
206 data-testid="sso-error"
207 className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
208 >
209 <p className="font-medium">Single sign-in failed</p>
210 <p className="mt-0.5 text-xs">{ssoErrorText}</p>
211 {ssoOrgSlug && (
212 <button
213 type="button"
214 data-testid="sso-retry"
215 onClick={() => startSso(ssoStartFor(ssoOrgSlug))}
216 className="mt-2 rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
217 >
218 Try SSO again
219 </button>
220 )}
221 </div>
222 )}
223
224 {ssoRequired && (
225 <div
226 role="alert"
227 data-testid="sso-required"
228 className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
229 >
230 <p className="font-medium">Sign in with SSO required</p>
231 <p className="mt-0.5 text-xs">
232 {ssoRequired.orgs.length > 0
233 ? `Your organization${ssoRequired.orgs.length > 1 ? "s" : ""} (${ssoRequired.orgs.join(", ")}) requires SSO sign-in.`
234 : "Your organization requires SSO sign-in."}
235 </p>
236 {ssoRequired.startUrl && (
237 <button
238 type="button"
239 data-testid="sso-continue"
240 onClick={() => startSso(ssoRequired.startUrl as string)}
241 className="mt-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700"
242 >
243 Continue with SSO
244 </button>
245 )}
246 </div>
247 )}
248
249 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
250 <FormError message={serverError} data-testid="auth-error" />
251 <Field label="Email" htmlFor="email" error={errors.email?.message}>
252 <Input
253 id="email"
254 type="email"
255 autoComplete="email"
256 placeholder="you@company.com"
257 data-testid="auth-email"
258 {...register("email")}
259 />
260 </Field>
261 <Field label="Password" htmlFor="password" error={errors.password?.message}>
262 <Input
263 id="password"
264 type="password"
265 autoComplete="current-password"
266 data-testid="auth-password"
267 {...register("password")}
268 />
269 </Field>
270 <div className="flex justify-end">
271 <Link href="/reset-password" className="text-sm text-muted-foreground hover:text-foreground">
272 Forgot password?
273 </Link>
274 </div>
275 <SubmitButton busy={isSubmitting} data-testid="auth-submit">
276 Sign in
277 </SubmitButton>
278 </form>
279
280 {ssoOrgs.length > 0 && (
281 <div className="space-y-2 border-t border-border pt-4">
282 {ssoOrgs.length > 1 && (
283 <p className="text-xs text-muted-foreground">
284 {ssoOrgs.length} organizations offer SSO sign-in for this email.
285 </p>
286 )}
287 {ssoOrgs.map((org) => (
288 <button
289 key={org.slug}
290 type="button"
291 data-testid="sso-start"
292 onClick={() => startSso(org.startUrl)}
293 className="w-full rounded-md border border-border bg-muted/40 px-3 py-2 text-sm font-medium hover:bg-muted"
294 >
295 Sign in with SSO — {org.slug}
296 </button>
297 ))}
298 </div>
299 )}
300
301 <p className="text-center text-sm text-muted-foreground">
302 No account?{" "}
303 <Link href="/register" className="text-foreground underline underline-offset-2">
304 Create one
305 </Link>
306 </p>
307
308 <p className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
309 <span className="font-medium text-foreground">Demo:</span> register any account on the
310 dev instance — the first user becomes workspace owner.
311 </p>
312
313 <p className="text-center text-xs text-muted-foreground">
314 Curious how this is built?{" "}
315 <a href="/source/" className="text-foreground underline underline-offset-2">
316 Browse the source
317 </a>{" "}
318 (read-only).
319 </p>
320 </div>
321 );
322}
323
324
331export default function LoginPageShell() {
332 return (
333 <Suspense>
334 <LoginPage />
335 </Suspense>
336 );
337}
338