nextworks 0.1.0-alpha.11 → 0.1.0-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +20 -9
  2. package/dist/kits/auth-core/.nextworks/docs/AUTH_CORE_README.md +3 -3
  3. package/dist/kits/auth-core/.nextworks/docs/AUTH_QUICKSTART.md +264 -244
  4. package/dist/kits/auth-core/app/(protected)/settings/profile/profile-form.tsx +120 -114
  5. package/dist/kits/auth-core/app/api/auth/forgot-password/route.ts +116 -114
  6. package/dist/kits/auth-core/app/api/auth/reset-password/route.ts +66 -63
  7. package/dist/kits/auth-core/app/api/auth/send-verify-email/route.ts +1 -1
  8. package/dist/kits/auth-core/app/api/users/[id]/route.ts +134 -127
  9. package/dist/kits/auth-core/app/auth/reset-password/page.tsx +186 -187
  10. package/dist/kits/auth-core/components/auth/dashboard.tsx +25 -2
  11. package/dist/kits/auth-core/components/auth/forgot-password-form.tsx +90 -90
  12. package/dist/kits/auth-core/components/auth/login-form.tsx +492 -467
  13. package/dist/kits/auth-core/components/auth/signup-form.tsx +28 -29
  14. package/dist/kits/auth-core/lib/auth.ts +46 -15
  15. package/dist/kits/auth-core/lib/forms/map-errors.ts +37 -11
  16. package/dist/kits/auth-core/lib/server/result.ts +45 -45
  17. package/dist/kits/auth-core/lib/validation/forms.ts +1 -2
  18. package/dist/kits/auth-core/package-deps.json +4 -2
  19. package/dist/kits/auth-core/types/next-auth.d.ts +1 -1
  20. package/dist/kits/blocks/.nextworks/docs/BLOCKS_QUICKSTART.md +2 -8
  21. package/dist/kits/blocks/.nextworks/docs/THEME_GUIDE.md +18 -1
  22. package/dist/kits/blocks/app/templates/productlaunch/page.tsx +0 -2
  23. package/dist/kits/blocks/components/sections/FAQ.tsx +0 -1
  24. package/dist/kits/blocks/components/sections/Newsletter.tsx +2 -2
  25. package/dist/kits/blocks/components/ui/switch.tsx +78 -78
  26. package/dist/kits/blocks/components/ui/theme-selector.tsx +1 -1
  27. package/dist/kits/blocks/lib/themes.ts +1 -0
  28. package/dist/kits/blocks/package-deps.json +4 -4
  29. package/dist/kits/data/.nextworks/docs/DATA_QUICKSTART.md +128 -112
  30. package/dist/kits/data/.nextworks/docs/DATA_README.md +2 -1
  31. package/dist/kits/data/app/api/posts/[id]/route.ts +83 -83
  32. package/dist/kits/data/app/api/posts/route.ts +136 -138
  33. package/dist/kits/data/app/api/seed-demo/route.ts +1 -2
  34. package/dist/kits/data/app/api/users/[id]/route.ts +29 -17
  35. package/dist/kits/data/app/api/users/check-email/route.ts +1 -1
  36. package/dist/kits/data/app/api/users/check-unique/route.ts +30 -27
  37. package/dist/kits/data/app/api/users/route.ts +0 -2
  38. package/dist/kits/data/app/examples/demo/create-post-form.tsx +108 -106
  39. package/dist/kits/data/app/examples/demo/page.tsx +2 -1
  40. package/dist/kits/data/app/examples/demo/seed-demo-button.tsx +1 -1
  41. package/dist/kits/data/components/admin/posts-manager.tsx +727 -719
  42. package/dist/kits/data/components/admin/users-manager.tsx +435 -432
  43. package/dist/kits/data/lib/server/result.ts +5 -2
  44. package/dist/kits/data/package-deps.json +1 -1
  45. package/dist/kits/data/scripts/seed-demo.mjs +1 -2
  46. package/dist/kits/forms/app/api/wizard/route.ts +76 -71
  47. package/dist/kits/forms/app/examples/forms/server-action/page.tsx +78 -71
  48. package/dist/kits/forms/components/hooks/useCheckUnique.ts +85 -79
  49. package/dist/kits/forms/components/ui/form/form-control.tsx +28 -28
  50. package/dist/kits/forms/components/ui/form/form-description.tsx +23 -22
  51. package/dist/kits/forms/components/ui/form/form-item.tsx +21 -21
  52. package/dist/kits/forms/components/ui/form/form-label.tsx +24 -24
  53. package/dist/kits/forms/components/ui/form/form-message.tsx +28 -29
  54. package/dist/kits/forms/components/ui/switch.tsx +78 -78
  55. package/dist/kits/forms/lib/forms/map-errors.ts +1 -1
  56. package/dist/kits/forms/lib/validation/forms.ts +1 -2
  57. package/package.json +1 -1
@@ -1,467 +1,492 @@
1
- "use client";
2
-
3
- import React, { useEffect, useState, type JSX } from "react";
4
- import { useRouter, useSearchParams } from "next/navigation";
5
- import { signIn, useSession } from "next-auth/react";
6
-
7
- import Link from "next/link";
8
- import { useForm } from "react-hook-form";
9
- import { zodResolver } from "@hookform/resolvers/zod";
10
- import { Button } from "@/components/ui/button";
11
- import { Input } from "@/components/ui/input";
12
- import { cn } from "@/lib/utils";
13
- import { loginSchema, type LoginFormValues } from "@/lib/validation/forms";
14
- import { Form } from "@/components/ui/form/form";
15
- import { FormField } from "@/components/ui/form/form-field";
16
- import { FormItem } from "@/components/ui/form/form-item";
17
- import { FormLabel } from "@/components/ui/form/form-label";
18
- import { FormMessage } from "@/components/ui/form/form-message";
19
- import { FormControl } from "@/components/ui/form/form-control";
20
- import { toast } from "sonner";
21
- import { mapApiErrorsToForm } from "@/lib/forms/map-errors";
22
-
23
- export interface LoginFormProps {
24
- id?: string;
25
- className?: string;
26
-
27
- /** Fallback redirect when no callbackUrl is present in the URL. Default: "/dashboard" */
28
- defaultRedirect?: string;
29
-
30
- /** Show the OAuth (GitHub) provider button. Default: true */
31
- showGithub?: boolean;
32
-
33
- /** Show the divider between providers and form fields. Default: true */
34
- showDivider?: boolean;
35
-
36
- /** Optional header text above the form */
37
- headingText?: { text?: string; className?: string } | null;
38
- /** Optional subheading text under the header */
39
- subheadingText?: { text?: string; className?: string } | null;
40
-
41
- /** Text labels you might want to override */
42
- labels?: {
43
- email?: string;
44
- password?: string;
45
- submit?: string;
46
- submitting?: string;
47
- github?: string;
48
- errorGeneric?: string;
49
- };
50
-
51
- /** Slots for styling overrides (similar to Navbar) */
52
- container?: { className?: string };
53
- headerWrapper?: { className?: string };
54
- providerWrapper?: { className?: string };
55
- providerButton?: {
56
- variant?:
57
- | "default"
58
- | "destructive"
59
- | "outline"
60
- | "secondary"
61
- | "ghost"
62
- | "link";
63
- size?: "default" | "sm" | "lg" | "icon";
64
- className?: string;
65
- };
66
- divider?: {
67
- wrapperClassName?: string;
68
- lineClassName?: string;
69
- textClassName?: string;
70
- };
71
- form?: { className?: string };
72
- field?: { className?: string };
73
- label?: { className?: string };
74
- input?: { className?: string };
75
- submitButton?: {
76
- variant?:
77
- | "default"
78
- | "destructive"
79
- | "outline"
80
- | "secondary"
81
- | "ghost"
82
- | "link";
83
- size?: "default" | "sm" | "lg" | "icon";
84
- className?: string;
85
- };
86
- alerts?: {
87
- errorClassName?: string;
88
- successClassName?: string;
89
- };
90
-
91
- /** ARIA label for the form */
92
- ariaLabel?: string;
93
- }
94
-
95
- export default function LoginForm({
96
- id,
97
- className,
98
- defaultRedirect = "/dashboard",
99
- showGithub = true,
100
- showDivider = true,
101
- headingText = {
102
- text: "Welcome back",
103
- className: "text-2xl font-bold text-foreground text-center",
104
- },
105
- subheadingText = {
106
- text: "Enter your credentials to sign in",
107
- className: "mt-1 text-sm text-muted-foreground text-center",
108
- },
109
- labels = {
110
- email: "Email",
111
- password: "Password",
112
- submit: "Log In",
113
- submitting: "Logging in...",
114
- github: "Continue with GitHub",
115
- errorGeneric: "Login failed. Please try again.",
116
- },
117
- container = { className: "mx-auto w-full max-w-md pt-6" },
118
- headerWrapper = { className: "mb-4" },
119
- providerWrapper = { className: "mb-6" },
120
- providerButton = {
121
- variant: "outline",
122
- size: "default",
123
- className:
124
- "w-full shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
125
- },
126
- divider = {
127
- wrapperClassName: "relative mb-6",
128
- lineClassName: "w-full border-t",
129
- textClassName: "bg-background text-muted-foreground px-2",
130
- },
131
- form = {
132
- className:
133
- "space-y-4 rounded-lg border border-border bg-card p-6 shadow-sm",
134
- },
135
- field = { className: "space-y-2" },
136
- label = { className: "" },
137
- input = { className: "" },
138
- submitButton = {
139
- variant: "default",
140
- size: "default",
141
- className:
142
- "w-full shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl",
143
- },
144
- alerts = {
145
- errorClassName:
146
- "mb-4 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive",
147
- successClassName:
148
- "mb-4 rounded-md border border-primary/20 bg-primary/10 p-3 text-sm text-foreground",
149
- },
150
- ariaLabel = "Login form",
151
- }: LoginFormProps): JSX.Element {
152
- const router = useRouter();
153
- const searchParams = useSearchParams();
154
- const { data: session, status } = useSession();
155
- const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
156
-
157
- const [error, setError] = useState<string | null | undefined>(null);
158
- const [success, setSuccess] = useState<string | null>(null);
159
- const [isGithubLoading, setIsGithubLoading] = useState(false);
160
- const [githubAvailable, setGithubAvailable] = useState(false);
161
- const [githubConfigured, setGithubConfigured] = useState(false);
162
-
163
- const formMethods = useForm<LoginFormValues>({
164
- resolver: zodResolver(loginSchema),
165
- defaultValues: { email: "", password: "" },
166
- });
167
- const {
168
- control,
169
- handleSubmit,
170
- setError: setFieldError,
171
- formState: { isSubmitting },
172
- } = formMethods;
173
-
174
- useEffect(() => {
175
- (async () => {
176
- try {
177
- const res = await fetch("/api/auth/providers");
178
- if (res.ok) {
179
- const json = await res.json();
180
- const github = json?.github ?? { configured: false, enabled: false };
181
- setGithubConfigured(!!github.configured);
182
- setGithubAvailable(!!github.enabled);
183
- } else {
184
- const providers = await (
185
- await import("next-auth/react")
186
- ).getProviders();
187
- setGithubAvailable(!!providers?.github);
188
- }
189
- } catch {
190
- try {
191
- const providers = await (
192
- await import("next-auth/react")
193
- ).getProviders();
194
- setGithubAvailable(!!providers?.github);
195
- } catch {
196
- setGithubAvailable(false);
197
- }
198
- }
199
- })();
200
- }, []);
201
-
202
- useEffect(() => {
203
- const STORAGE_KEY = "signup_toast_shown";
204
- const isSignup = searchParams.get("signup") === "1";
205
-
206
- if (isSignup) {
207
- try {
208
- if (!sessionStorage.getItem(STORAGE_KEY)) {
209
- sessionStorage.setItem(STORAGE_KEY, "1");
210
- setSuccess("Account created. You can now sign in.");
211
- toast.success("Account created. You can now sign in.", {
212
- id: "signup-success",
213
- });
214
-
215
- const params = new URLSearchParams(searchParams);
216
- params.delete("signup");
217
- router.replace(`/auth/login?${params.toString()}`, { scroll: false });
218
- }
219
- } catch {
220
- setSuccess("Account created. You can now sign in.");
221
- toast.success("Account created. You can now sign in.", {
222
- id: "signup-success",
223
- });
224
- const params = new URLSearchParams(searchParams);
225
- params.delete("signup");
226
- router.replace(`/auth/login?${params.toString()}`, { scroll: false });
227
- }
228
- } else {
229
- try {
230
- sessionStorage.removeItem(STORAGE_KEY);
231
- } catch {}
232
- }
233
-
234
- if (status === "authenticated" && session) {
235
- const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
236
- const resolve = (cb: string) => {
237
- try {
238
- const u = new URL(cb);
239
- if (u.origin === window.location.origin)
240
- return `${u.pathname}${u.search}${u.hash}`;
241
- } catch {}
242
- return cb;
243
- };
244
- window.location.assign(resolve(callbackUrl));
245
- }
246
- }, [status, session, router, searchParams, defaultRedirect]);
247
-
248
- const onSubmit = async (data: LoginFormValues) => {
249
- setError(null);
250
- const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
251
-
252
- try {
253
- const csrfRes = await fetch("/api/auth/csrf", { credentials: "include" });
254
- const csrfJson = await csrfRes.json().catch(() => null);
255
- const csrfToken = csrfJson?.csrfToken;
256
-
257
- const body = new URLSearchParams();
258
- if (csrfToken) body.set("csrfToken", csrfToken);
259
- body.set("callbackUrl", callbackUrl);
260
- body.set("json", "true");
261
- body.set("email", data.email);
262
- body.set("password", data.password);
263
-
264
- const res = await fetch("/api/auth/callback/credentials", {
265
- method: "POST",
266
- credentials: "include",
267
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
268
- body: body.toString(),
269
- redirect: "manual",
270
- });
271
-
272
- const text = await res.text().catch(() => null);
273
- let parsed: any = null;
274
- try {
275
- parsed = text ? JSON.parse(text) : null;
276
- } catch {}
277
-
278
- if (!res.ok) {
279
- if (parsed && typeof parsed === "object") {
280
- const msg = mapApiErrorsToForm(formMethods, parsed);
281
- setError(
282
- msg ||
283
- parsed.message ||
284
- (labels.errorGeneric ?? "Login failed. Please try again."),
285
- );
286
- } else {
287
- setError(labels.errorGeneric ?? "Login failed. Please try again.");
288
- }
289
- return;
290
- }
291
-
292
- toast.success("Logged in successfully");
293
- const dest = (parsed && parsed.url) || callbackUrl;
294
- const resolve = (cb: string) => {
295
- try {
296
- const u = new URL(cb);
297
- if (u.origin === window.location.origin)
298
- return `${u.pathname}${u.search}${u.hash}`;
299
- } catch {}
300
- return cb;
301
- };
302
-
303
- window.location.assign(resolve(dest));
304
- } catch (err) {
305
- setError(labels.errorGeneric ?? "Login failed. Please try again.");
306
- }
307
- };
308
-
309
- const handleGithubLogin = async () => {
310
- setIsGithubLoading(true);
311
- setError(null);
312
- const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
313
-
314
- if (!githubConfigured && githubAvailable) {
315
- setError(
316
- "GitHub provider is not configured on the server. Contact the site administrator.",
317
- );
318
- setIsGithubLoading(false);
319
- return;
320
- }
321
-
322
- const result = await signIn("github", { redirect: false, callbackUrl });
323
- if (result?.error) {
324
- setError("GitHub login failed. Please try again.");
325
- setIsGithubLoading(false);
326
- }
327
- };
328
-
329
- return (
330
- <div
331
- id={id}
332
- className={cn(container.className, className)}
333
- aria-label={ariaLabel}
334
- >
335
- {(headingText?.text || subheadingText?.text) && (
336
- <div className={cn(headerWrapper.className)}>
337
- {headingText?.text && (
338
- <h2 className={cn("font-poppins", headingText.className)}>
339
- {headingText.text}
340
- </h2>
341
- )}
342
- {subheadingText?.text && (
343
- <p className={cn(subheadingText.className)}>
344
- {subheadingText.text}
345
- </p>
346
- )}
347
- </div>
348
- )}
349
-
350
- {error && (
351
- <div
352
- className={cn(alerts.errorClassName)}
353
- role="alert"
354
- aria-live="polite"
355
- >
356
- {error}
357
- </div>
358
- )}
359
-
360
- {showGithub && githubAvailable && (
361
- <>
362
- <div className={cn(providerWrapper.className)}>
363
- <Button
364
- variant={providerButton.variant}
365
- size={providerButton.size}
366
- className={cn(providerButton.className)}
367
- onClick={handleGithubLogin}
368
- disabled={isGithubLoading || !githubConfigured}
369
- aria-label={labels.github}
370
- >
371
- {labels.github}
372
- </Button>
373
- </div>
374
-
375
- {!githubConfigured && githubAvailable && (
376
- <div className="text-muted-foreground mb-4 text-center text-sm">
377
- GitHub sign-in is visible for demos but not configured. Please set
378
- GITHUB_ID and GITHUB_SECRET in your environment to enable it.
379
- </div>
380
- )}
381
-
382
- {showDivider && (
383
- <div className={cn(divider.wrapperClassName)}>
384
- <div className="absolute inset-0 flex items-center">
385
- <span className={cn(divider.lineClassName)} />
386
- </div>
387
- <div className="relative flex justify-center text-xs">
388
- <span className={cn(divider.textClassName)}>or</span>
389
- </div>
390
- </div>
391
- )}
392
- </>
393
- )}
394
-
395
- <Form methods={formMethods}>
396
- <form onSubmit={handleSubmit(onSubmit)} className={cn(form.className)}>
397
- <FormField
398
- control={control}
399
- name="email"
400
- render={({ field: f }) => (
401
- <FormItem className={cn(field.className)}>
402
- <FormLabel className={cn(label.className)}>
403
- {labels.email}
404
- </FormLabel>
405
- <FormControl>
406
- <Input
407
- id="email"
408
- type="email"
409
- inputMode="email"
410
- autoComplete="email"
411
- placeholder="you@example.com"
412
- className={cn(input.className)}
413
- {...f}
414
- />
415
- </FormControl>
416
- <FormMessage />
417
- </FormItem>
418
- )}
419
- />
420
-
421
- <FormField
422
- control={control}
423
- name="password"
424
- render={({ field: f }) => (
425
- <FormItem className={cn(field.className)}>
426
- <FormLabel className={cn(label.className)}>
427
- {labels.password}
428
- </FormLabel>
429
- <FormControl>
430
- <Input
431
- id="password"
432
- type="password"
433
- autoComplete="current-password"
434
- placeholder="At least 6 characters"
435
- className={cn(input.className)}
436
- {...f}
437
- />
438
- </FormControl>
439
- <FormMessage />
440
- </FormItem>
441
- )}
442
- />
443
-
444
- <Button
445
- type="submit"
446
- variant={submitButton.variant}
447
- size={submitButton.size}
448
- className={cn(submitButton.className)}
449
- disabled={isSubmitting}
450
- aria-label={isSubmitting ? labels.submitting : labels.submit}
451
- >
452
- {isSubmitting ? labels.submitting : labels.submit}
453
- </Button>
454
- </form>
455
- </Form>
456
- <p className="mt-3 text-center text-sm">
457
- Don&apos;t have an account?{" "}
458
- <Link
459
- href={`/auth/signup?callbackUrl=${encodeURIComponent(callbackUrl)}`}
460
- className="text-primary underline"
461
- >
462
- Sign up
463
- </Link>
464
- </p>
465
- </div>
466
- );
467
- }
1
+ "use client";
2
+
3
+ import React, { useEffect, useState, type JSX } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { signIn, useSession } from "next-auth/react";
6
+
7
+ import Link from "next/link";
8
+ import { useForm } from "react-hook-form";
9
+ import { zodResolver } from "@hookform/resolvers/zod";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Input } from "@/components/ui/input";
12
+ import { cn } from "@/lib/utils";
13
+ import { loginSchema, type LoginFormValues } from "@/lib/validation/forms";
14
+ import { Form } from "@/components/ui/form/form";
15
+ import { FormField } from "@/components/ui/form/form-field";
16
+ import { FormItem } from "@/components/ui/form/form-item";
17
+ import { FormLabel } from "@/components/ui/form/form-label";
18
+ import { FormMessage } from "@/components/ui/form/form-message";
19
+ import { FormControl } from "@/components/ui/form/form-control";
20
+ import { toast } from "sonner";
21
+ import { mapApiErrorsToForm } from "@/lib/forms/map-errors";
22
+
23
+ export interface LoginFormProps {
24
+ id?: string;
25
+ className?: string;
26
+
27
+ /** Fallback redirect when no callbackUrl is present in the URL. Default: "/dashboard" */
28
+ defaultRedirect?: string;
29
+
30
+ /** Show the OAuth (GitHub) provider button. Default: true */
31
+ showGithub?: boolean;
32
+
33
+ /** Show the divider between providers and form fields. Default: true */
34
+ showDivider?: boolean;
35
+
36
+ /** Optional header text above the form */
37
+ headingText?: { text?: string; className?: string } | null;
38
+ /** Optional subheading text under the header */
39
+ subheadingText?: { text?: string; className?: string } | null;
40
+
41
+ /** Text labels you might want to override */
42
+ labels?: {
43
+ email?: string;
44
+ password?: string;
45
+ submit?: string;
46
+ submitting?: string;
47
+ github?: string;
48
+ errorGeneric?: string;
49
+ };
50
+
51
+ /** Slots for styling overrides (similar to Navbar) */
52
+ container?: { className?: string };
53
+ headerWrapper?: { className?: string };
54
+ providerWrapper?: { className?: string };
55
+ providerButton?: {
56
+ variant?:
57
+ | "default"
58
+ | "destructive"
59
+ | "outline"
60
+ | "secondary"
61
+ | "ghost"
62
+ | "link";
63
+ size?: "default" | "sm" | "lg" | "icon";
64
+ className?: string;
65
+ };
66
+ divider?: {
67
+ wrapperClassName?: string;
68
+ lineClassName?: string;
69
+ textClassName?: string;
70
+ };
71
+ form?: { className?: string };
72
+ field?: { className?: string };
73
+ label?: { className?: string };
74
+ input?: { className?: string };
75
+ submitButton?: {
76
+ variant?:
77
+ | "default"
78
+ | "destructive"
79
+ | "outline"
80
+ | "secondary"
81
+ | "ghost"
82
+ | "link";
83
+ size?: "default" | "sm" | "lg" | "icon";
84
+ className?: string;
85
+ };
86
+ alerts?: {
87
+ errorClassName?: string;
88
+ successClassName?: string;
89
+ };
90
+
91
+ /** ARIA label for the form */
92
+ ariaLabel?: string;
93
+ }
94
+
95
+ export default function LoginForm({
96
+ id,
97
+ className,
98
+ defaultRedirect = "/dashboard",
99
+ showGithub = true,
100
+ showDivider = true,
101
+ headingText = {
102
+ text: "Welcome back",
103
+ className: "text-2xl font-bold text-foreground text-center",
104
+ },
105
+ subheadingText = {
106
+ text: "Enter your credentials to sign in",
107
+ className: "mt-1 text-sm text-muted-foreground text-center",
108
+ },
109
+ labels = {
110
+ email: "Email",
111
+ password: "Password",
112
+ submit: "Log In",
113
+ submitting: "Logging in...",
114
+ github: "Continue with GitHub",
115
+ errorGeneric: "Login failed. Please try again.",
116
+ },
117
+ container = { className: "mx-auto w-full max-w-md pt-6" },
118
+ headerWrapper = { className: "mb-4" },
119
+ providerWrapper = { className: "mb-6" },
120
+ providerButton = {
121
+ variant: "outline",
122
+ size: "default",
123
+ className:
124
+ "w-full shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
125
+ },
126
+ divider = {
127
+ wrapperClassName: "relative mb-6",
128
+ lineClassName: "w-full border-t",
129
+ textClassName: "bg-background text-muted-foreground px-2",
130
+ },
131
+ form = {
132
+ className:
133
+ "space-y-4 rounded-lg border border-border bg-card p-6 shadow-sm",
134
+ },
135
+ field = { className: "space-y-2" },
136
+ label = { className: "" },
137
+ input = { className: "" },
138
+ submitButton = {
139
+ variant: "default",
140
+ size: "default",
141
+ className:
142
+ "w-full shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl",
143
+ },
144
+ alerts = {
145
+ errorClassName:
146
+ "mb-4 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive",
147
+ successClassName:
148
+ "mb-4 rounded-md border border-primary/20 bg-primary/10 p-3 text-sm text-foreground",
149
+ },
150
+ ariaLabel = "Login form",
151
+ }: LoginFormProps): JSX.Element {
152
+ const router = useRouter();
153
+ const searchParams = useSearchParams();
154
+ const { data: session, status } = useSession();
155
+ const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
156
+
157
+ const [error, setError] = useState<string | null | undefined>(null);
158
+ const [success, setSuccess] = useState<string | null>(null);
159
+
160
+ const [isGithubLoading, setIsGithubLoading] = useState(false);
161
+ const [githubAvailable, setGithubAvailable] = useState(false);
162
+ const [githubConfigured, setGithubConfigured] = useState(false);
163
+
164
+ const formMethods = useForm<LoginFormValues>({
165
+ resolver: zodResolver(loginSchema),
166
+ defaultValues: { email: "", password: "" },
167
+ });
168
+ const {
169
+ control,
170
+ handleSubmit,
171
+ formState: { isSubmitting },
172
+ } = formMethods;
173
+
174
+ useEffect(() => {
175
+ (async () => {
176
+ try {
177
+ const res = await fetch("/api/auth/providers");
178
+ if (res.ok) {
179
+ const json = await res.json();
180
+ const github = json?.github ?? { configured: false, enabled: false };
181
+ setGithubConfigured(!!github.configured);
182
+ setGithubAvailable(!!github.enabled);
183
+ } else {
184
+ const providers = await (
185
+ await import("next-auth/react")
186
+ ).getProviders();
187
+ setGithubAvailable(!!providers?.github);
188
+ }
189
+ } catch {
190
+ try {
191
+ const providers = await (
192
+ await import("next-auth/react")
193
+ ).getProviders();
194
+ setGithubAvailable(!!providers?.github);
195
+ } catch {
196
+ setGithubAvailable(false);
197
+ }
198
+ }
199
+ })();
200
+ }, []);
201
+
202
+ useEffect(() => {
203
+ const STORAGE_KEY = "signup_toast_shown";
204
+ const isSignup = searchParams.get("signup") === "1";
205
+
206
+ if (isSignup) {
207
+ try {
208
+ if (!sessionStorage.getItem(STORAGE_KEY)) {
209
+ sessionStorage.setItem(STORAGE_KEY, "1");
210
+ setSuccess("Account created. You can now sign in.");
211
+ toast.success("Account created. You can now sign in.", {
212
+ id: "signup-success",
213
+ });
214
+
215
+ const params = new URLSearchParams(searchParams);
216
+ params.delete("signup");
217
+ router.replace(`/auth/login?${params.toString()}`, { scroll: false });
218
+ }
219
+ } catch {
220
+ setSuccess("Account created. You can now sign in.");
221
+ toast.success("Account created. You can now sign in.", {
222
+ id: "signup-success",
223
+ });
224
+ const params = new URLSearchParams(searchParams);
225
+ params.delete("signup");
226
+ router.replace(`/auth/login?${params.toString()}`, { scroll: false });
227
+ }
228
+ } else {
229
+ try {
230
+ sessionStorage.removeItem(STORAGE_KEY);
231
+ } catch {}
232
+ }
233
+
234
+ if (status === "authenticated" && session) {
235
+ const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
236
+ const resolve = (cb: string) => {
237
+ try {
238
+ const u = new URL(cb);
239
+ if (u.origin === window.location.origin)
240
+ return `${u.pathname}${u.search}${u.hash}`;
241
+ } catch {}
242
+ return cb;
243
+ };
244
+ window.location.assign(resolve(callbackUrl));
245
+ }
246
+ }, [status, session, router, searchParams, defaultRedirect]);
247
+
248
+ const onSubmit = async (data: LoginFormValues) => {
249
+ setError(null);
250
+ setSuccess(null);
251
+
252
+ const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
253
+
254
+ try {
255
+ const csrfRes = await fetch("/api/auth/csrf", { credentials: "include" });
256
+ const csrfJson = await csrfRes.json().catch(() => null);
257
+ const csrfToken = csrfJson?.csrfToken;
258
+
259
+ const body = new URLSearchParams();
260
+ if (csrfToken) body.set("csrfToken", csrfToken);
261
+ body.set("callbackUrl", callbackUrl);
262
+ body.set("json", "true");
263
+ body.set("email", data.email);
264
+ body.set("password", data.password);
265
+
266
+ const res = await fetch("/api/auth/callback/credentials", {
267
+ method: "POST",
268
+ credentials: "include",
269
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
270
+ body: body.toString(),
271
+ redirect: "manual",
272
+ });
273
+
274
+ const text = await res.text().catch(() => null);
275
+ let parsed: unknown = null;
276
+ try {
277
+ parsed = text ? JSON.parse(text) : null;
278
+ } catch {}
279
+
280
+ if (!res.ok) {
281
+ if (parsed && typeof parsed === "object") {
282
+ const msg = mapApiErrorsToForm(formMethods, parsed as never);
283
+
284
+
285
+ const parsedMessage =
286
+ "message" in parsed && typeof parsed.message === "string"
287
+ ? parsed.message
288
+ : null;
289
+
290
+ setError(
291
+ msg ||
292
+ parsedMessage ||
293
+ (labels.errorGeneric ?? "Login failed. Please try again."),
294
+ );
295
+ } else {
296
+ setError(labels.errorGeneric ?? "Login failed. Please try again.");
297
+ }
298
+ return;
299
+ }
300
+
301
+ setSuccess("Logged in successfully");
302
+ toast.success("Logged in successfully");
303
+
304
+ const dest =
305
+ parsed && typeof parsed === "object" && "url" in parsed
306
+ ? String(parsed.url)
307
+ : callbackUrl;
308
+ const resolve = (cb: string) => {
309
+ try {
310
+ const u = new URL(cb);
311
+ if (u.origin === window.location.origin)
312
+ return `${u.pathname}${u.search}${u.hash}`;
313
+ } catch {}
314
+ return cb;
315
+ };
316
+
317
+ window.location.assign(resolve(dest));
318
+ } catch {
319
+ setError(labels.errorGeneric ?? "Login failed. Please try again.");
320
+ }
321
+ };
322
+
323
+ const handleGithubLogin = async () => {
324
+ setIsGithubLoading(true);
325
+ setError(null);
326
+ const callbackUrl = searchParams.get("callbackUrl") || defaultRedirect;
327
+
328
+ if (!githubConfigured && githubAvailable) {
329
+ setError(
330
+ "GitHub provider is not configured on the server. Contact the site administrator.",
331
+ );
332
+ setIsGithubLoading(false);
333
+ return;
334
+ }
335
+
336
+ const result = await signIn("github", { redirect: false, callbackUrl });
337
+ if (result?.error) {
338
+ setError("GitHub login failed. Please try again.");
339
+ setIsGithubLoading(false);
340
+ }
341
+ };
342
+
343
+ return (
344
+ <div
345
+ id={id}
346
+ className={cn(container.className, className)}
347
+ aria-label={ariaLabel}
348
+ >
349
+ {(headingText?.text || subheadingText?.text) && (
350
+ <div className={cn(headerWrapper.className)}>
351
+ {headingText?.text && (
352
+ <h2 className={cn("font-poppins", headingText.className)}>
353
+ {headingText.text}
354
+ </h2>
355
+ )}
356
+ {subheadingText?.text && (
357
+ <p className={cn(subheadingText.className)}>
358
+ {subheadingText.text}
359
+ </p>
360
+ )}
361
+ </div>
362
+ )}
363
+
364
+ {error && (
365
+ <div
366
+ className={cn(alerts.errorClassName)}
367
+ role="alert"
368
+ aria-live="polite"
369
+ >
370
+ {error}
371
+ </div>
372
+ )}
373
+
374
+ {success && (
375
+ <div
376
+ className={cn(alerts.successClassName)}
377
+ role="status"
378
+ aria-live="polite"
379
+ >
380
+ {success}
381
+ </div>
382
+ )}
383
+
384
+
385
+ {showGithub && githubAvailable && (
386
+ <>
387
+ <div className={cn(providerWrapper.className)}>
388
+ <Button
389
+ variant={providerButton.variant}
390
+ size={providerButton.size}
391
+ className={cn(providerButton.className)}
392
+ onClick={handleGithubLogin}
393
+ disabled={isGithubLoading || !githubConfigured}
394
+ aria-label={labels.github}
395
+ >
396
+ {labels.github}
397
+ </Button>
398
+ </div>
399
+
400
+ {!githubConfigured && githubAvailable && (
401
+ <div className="text-muted-foreground mb-4 text-center text-sm">
402
+ GitHub sign-in is visible for demos but not configured. Please set
403
+ GITHUB_ID and GITHUB_SECRET in your environment to enable it.
404
+ </div>
405
+ )}
406
+
407
+ {showDivider && (
408
+ <div className={cn(divider.wrapperClassName)}>
409
+ <div className="absolute inset-0 flex items-center">
410
+ <span className={cn(divider.lineClassName)} />
411
+ </div>
412
+ <div className="relative flex justify-center text-xs">
413
+ <span className={cn(divider.textClassName)}>or</span>
414
+ </div>
415
+ </div>
416
+ )}
417
+ </>
418
+ )}
419
+
420
+ <Form methods={formMethods}>
421
+ <form onSubmit={handleSubmit(onSubmit)} className={cn(form.className)}>
422
+ <FormField
423
+ control={control}
424
+ name="email"
425
+ render={({ field: f }) => (
426
+ <FormItem className={cn(field.className)}>
427
+ <FormLabel className={cn(label.className)}>
428
+ {labels.email}
429
+ </FormLabel>
430
+ <FormControl>
431
+ <Input
432
+ id="email"
433
+ type="email"
434
+ inputMode="email"
435
+ autoComplete="email"
436
+ placeholder="you@example.com"
437
+ className={cn(input.className)}
438
+ {...f}
439
+ />
440
+ </FormControl>
441
+ <FormMessage />
442
+ </FormItem>
443
+ )}
444
+ />
445
+
446
+ <FormField
447
+ control={control}
448
+ name="password"
449
+ render={({ field: f }) => (
450
+ <FormItem className={cn(field.className)}>
451
+ <FormLabel className={cn(label.className)}>
452
+ {labels.password}
453
+ </FormLabel>
454
+ <FormControl>
455
+ <Input
456
+ id="password"
457
+ type="password"
458
+ autoComplete="current-password"
459
+ placeholder="At least 6 characters"
460
+ className={cn(input.className)}
461
+ {...f}
462
+ />
463
+ </FormControl>
464
+ <FormMessage />
465
+ </FormItem>
466
+ )}
467
+ />
468
+
469
+ <Button
470
+ type="submit"
471
+ variant={submitButton.variant}
472
+ size={submitButton.size}
473
+ className={cn(submitButton.className)}
474
+ disabled={isSubmitting}
475
+ aria-label={isSubmitting ? labels.submitting : labels.submit}
476
+ >
477
+ {isSubmitting ? labels.submitting : labels.submit}
478
+ </Button>
479
+ </form>
480
+ </Form>
481
+ <p className="mt-3 text-center text-sm">
482
+ Don&apos;t have an account?{" "}
483
+ <Link
484
+ href={`/auth/signup?callbackUrl=${encodeURIComponent(callbackUrl)}`}
485
+ className="text-primary underline"
486
+ >
487
+ Sign up
488
+ </Link>
489
+ </p>
490
+ </div>
491
+ );
492
+ }