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.
- package/README.md +20 -9
- package/dist/kits/auth-core/.nextworks/docs/AUTH_CORE_README.md +3 -3
- package/dist/kits/auth-core/.nextworks/docs/AUTH_QUICKSTART.md +264 -244
- package/dist/kits/auth-core/app/(protected)/settings/profile/profile-form.tsx +120 -114
- package/dist/kits/auth-core/app/api/auth/forgot-password/route.ts +116 -114
- package/dist/kits/auth-core/app/api/auth/reset-password/route.ts +66 -63
- package/dist/kits/auth-core/app/api/auth/send-verify-email/route.ts +1 -1
- package/dist/kits/auth-core/app/api/users/[id]/route.ts +134 -127
- package/dist/kits/auth-core/app/auth/reset-password/page.tsx +186 -187
- package/dist/kits/auth-core/components/auth/dashboard.tsx +25 -2
- package/dist/kits/auth-core/components/auth/forgot-password-form.tsx +90 -90
- package/dist/kits/auth-core/components/auth/login-form.tsx +492 -467
- package/dist/kits/auth-core/components/auth/signup-form.tsx +28 -29
- package/dist/kits/auth-core/lib/auth.ts +46 -15
- package/dist/kits/auth-core/lib/forms/map-errors.ts +37 -11
- package/dist/kits/auth-core/lib/server/result.ts +45 -45
- package/dist/kits/auth-core/lib/validation/forms.ts +1 -2
- package/dist/kits/auth-core/package-deps.json +4 -2
- package/dist/kits/auth-core/types/next-auth.d.ts +1 -1
- package/dist/kits/blocks/.nextworks/docs/BLOCKS_QUICKSTART.md +2 -8
- package/dist/kits/blocks/.nextworks/docs/THEME_GUIDE.md +18 -1
- package/dist/kits/blocks/app/templates/productlaunch/page.tsx +0 -2
- package/dist/kits/blocks/components/sections/FAQ.tsx +0 -1
- package/dist/kits/blocks/components/sections/Newsletter.tsx +2 -2
- package/dist/kits/blocks/components/ui/switch.tsx +78 -78
- package/dist/kits/blocks/components/ui/theme-selector.tsx +1 -1
- package/dist/kits/blocks/lib/themes.ts +1 -0
- package/dist/kits/blocks/package-deps.json +4 -4
- package/dist/kits/data/.nextworks/docs/DATA_QUICKSTART.md +128 -112
- package/dist/kits/data/.nextworks/docs/DATA_README.md +2 -1
- package/dist/kits/data/app/api/posts/[id]/route.ts +83 -83
- package/dist/kits/data/app/api/posts/route.ts +136 -138
- package/dist/kits/data/app/api/seed-demo/route.ts +1 -2
- package/dist/kits/data/app/api/users/[id]/route.ts +29 -17
- package/dist/kits/data/app/api/users/check-email/route.ts +1 -1
- package/dist/kits/data/app/api/users/check-unique/route.ts +30 -27
- package/dist/kits/data/app/api/users/route.ts +0 -2
- package/dist/kits/data/app/examples/demo/create-post-form.tsx +108 -106
- package/dist/kits/data/app/examples/demo/page.tsx +2 -1
- package/dist/kits/data/app/examples/demo/seed-demo-button.tsx +1 -1
- package/dist/kits/data/components/admin/posts-manager.tsx +727 -719
- package/dist/kits/data/components/admin/users-manager.tsx +435 -432
- package/dist/kits/data/lib/server/result.ts +5 -2
- package/dist/kits/data/package-deps.json +1 -1
- package/dist/kits/data/scripts/seed-demo.mjs +1 -2
- package/dist/kits/forms/app/api/wizard/route.ts +76 -71
- package/dist/kits/forms/app/examples/forms/server-action/page.tsx +78 -71
- package/dist/kits/forms/components/hooks/useCheckUnique.ts +85 -79
- package/dist/kits/forms/components/ui/form/form-control.tsx +28 -28
- package/dist/kits/forms/components/ui/form/form-description.tsx +23 -22
- package/dist/kits/forms/components/ui/form/form-item.tsx +21 -21
- package/dist/kits/forms/components/ui/form/form-label.tsx +24 -24
- package/dist/kits/forms/components/ui/form/form-message.tsx +28 -29
- package/dist/kits/forms/components/ui/switch.tsx +78 -78
- package/dist/kits/forms/lib/forms/map-errors.ts +1 -1
- package/dist/kits/forms/lib/validation/forms.ts +1 -2
- 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
|
-
|
|
160
|
-
const [
|
|
161
|
-
const [
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
249
|
-
setError(null);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
body
|
|
260
|
-
body.set("
|
|
261
|
-
body.set("
|
|
262
|
-
body.set("
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
{
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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'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
|
+
}
|