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,127 +1,134 @@
1
- import { NextRequest } from "next/server";
2
- import { prisma } from "@/lib/prisma";
3
- import type { Prisma } from "@prisma/client";
4
- import { jsonOk, jsonFail } from "@/lib/server/result";
5
- import { getServerSession } from "next-auth";
6
- import { authOptions } from "@/lib/auth";
7
-
8
- // Ensure Prisma runs in Node.js (not Edge)
9
- export const runtime = "nodejs";
10
-
11
- type RouteContext = { params: Promise<{ id: string }> };
12
-
13
- // Type guards/helpers (kept minimal here)
14
- const hasErrorCode = (e: unknown, code: string): boolean =>
15
- typeof e === "object" &&
16
- e !== null &&
17
- "code" in e &&
18
- typeof (e as { code?: unknown }).code === "string" &&
19
- (e as { code: string }).code === code;
20
-
21
- export async function GET(_req: NextRequest, { params }: RouteContext) {
22
- try {
23
- const { id } = await params; // async params in Next.js 15
24
-
25
- const user = await prisma.user.findUnique({
26
- where: { id },
27
- select: { id: true, name: true, email: true, role: true },
28
- });
29
-
30
- if (!user) {
31
- return jsonFail("Not found", { status: 404 });
32
- }
33
-
34
- return jsonOk(user);
35
- } catch (e) {
36
- console.error("GET /api/users/[id] error:", e);
37
- return jsonFail("Failed to fetch user", { status: 500 });
38
- }
39
- }
40
-
41
- export async function PUT(req: NextRequest, { params }: RouteContext) {
42
- try {
43
- // Only admins may update other users. Allow self-update as well.
44
- const session = await getServerSession(authOptions);
45
- if (!session?.user) return jsonFail("Unauthorized", { status: 401 });
46
-
47
- const { id } = await params;
48
- const isAdmin = (session.user as { role?: string }).role === "admin";
49
- const isSelf = (session.user as { id?: string }).id === id;
50
- if (!isAdmin && !isSelf) return jsonFail("Forbidden", { status: 403 });
51
-
52
- const body: unknown = await req.json();
53
- if (typeof body !== "object" || body === null) {
54
- return jsonFail("Body must be a JSON object", { status: 400 });
55
- }
56
-
57
- // Validate with Zod (imported lazily to keep this route light)
58
- const { userUpdateSchema } = await import("@/lib/validation/forms");
59
- try {
60
- const parsed = userUpdateSchema.parse(body);
61
-
62
- // Build Prisma-compatible update object
63
- const data: Prisma.UserUpdateInput = {};
64
- if (parsed.name !== undefined)
65
- data.name = parsed.name === "" ? null : parsed.name;
66
- if (parsed.email !== undefined) data.email = parsed.email;
67
- if (parsed.image !== undefined)
68
- data.image = parsed.image === "" ? null : parsed.image;
69
- if (parsed.password !== undefined) {
70
- // Hash password before saving
71
- const { hashPassword } = await import("@/lib/hash");
72
- data.password = await hashPassword(parsed.password as string);
73
- }
74
- if (parsed.emailVerified !== undefined)
75
- data.emailVerified = parsed.emailVerified as any;
76
-
77
- const updated = await prisma.user.update({
78
- where: { id },
79
- data,
80
- });
81
-
82
- return jsonOk(updated, { status: 200, message: "User updated" });
83
- } catch (err) {
84
- // Zod errors
85
- if (err && typeof err === "object" && "issues" in (err as any)) {
86
- const { jsonFromZod } = await import("@/lib/server/result");
87
- return jsonFromZod(err as any, {
88
- status: 400,
89
- message: "Validation failed",
90
- });
91
- }
92
- throw err;
93
- }
94
- } catch (e) {
95
- console.error("PUT /api/users/[id] error:", e);
96
- if (hasErrorCode(e, "P2025")) {
97
- return jsonFail("Not found", { status: 404 });
98
- }
99
- return jsonFail("Failed to update user", { status: 500 });
100
- }
101
- }
102
-
103
- export async function DELETE(_req: NextRequest, { params }: RouteContext) {
104
- try {
105
- const { requireAdminApi } = await import("@/lib/auth-helpers");
106
- const session = await requireAdminApi();
107
- if (!session) return jsonFail("Forbidden", { status: 403 });
108
-
109
- const { id } = await params;
110
-
111
- // Clean up dependent records first to avoid FK violations
112
- await prisma.$transaction([
113
- prisma.account.deleteMany({ where: { userId: id } }),
114
- prisma.session.deleteMany({ where: { userId: id } }),
115
- prisma.post.deleteMany({ where: { authorId: id } }),
116
- prisma.user.delete({ where: { id } }),
117
- ]);
118
-
119
- return jsonOk({ ok: true }, { status: 200, message: "User deleted" });
120
- } catch (e) {
121
- console.error("DELETE /api/users/[id] error:", e);
122
- if (hasErrorCode(e, "P2025")) {
123
- return jsonFail("Not found", { status: 404 });
124
- }
125
- return jsonFail("Failed to delete user", { status: 500 });
126
- }
127
- }
1
+ import { NextRequest } from "next/server";
2
+ import { prisma } from "@/lib/prisma";
3
+ import { jsonOk, jsonFail } from "@/lib/server/result";
4
+ import { getServerSession } from "next-auth";
5
+ import { authOptions } from "@/lib/auth";
6
+
7
+ // Ensure Prisma runs in Node.js (not Edge)
8
+ export const runtime = "nodejs";
9
+
10
+ type RouteContext = { params: Promise<{ id: string }> };
11
+
12
+ // Type guards/helpers (kept minimal here)
13
+ const hasErrorCode = (e: unknown, code: string): boolean =>
14
+ typeof e === "object" &&
15
+ e !== null &&
16
+ "code" in e &&
17
+ typeof (e as { code?: unknown }).code === "string" &&
18
+ (e as { code: string }).code === code;
19
+
20
+ export async function GET(_req: NextRequest, { params }: RouteContext) {
21
+ try {
22
+ const { id } = await params; // async params in Next.js 15
23
+
24
+ const user = await prisma.user.findUnique({
25
+ where: { id },
26
+ select: { id: true, name: true, email: true, role: true },
27
+ });
28
+
29
+ if (!user) {
30
+ return jsonFail("Not found", { status: 404 });
31
+ }
32
+
33
+ return jsonOk(user);
34
+ } catch (e) {
35
+ console.error("GET /api/users/[id] error:", e);
36
+ return jsonFail("Failed to fetch user", { status: 500 });
37
+ }
38
+ }
39
+
40
+ export async function PUT(req: NextRequest, { params }: RouteContext) {
41
+ try {
42
+ // Only admins may update other users. Allow self-update as well.
43
+ const session = await getServerSession(authOptions);
44
+ if (!session?.user) return jsonFail("Unauthorized", { status: 401 });
45
+
46
+ const { id } = await params;
47
+ const isAdmin = (session.user as { role?: string }).role === "admin";
48
+ const isSelf = (session.user as { id?: string }).id === id;
49
+ if (!isAdmin && !isSelf) return jsonFail("Forbidden", { status: 403 });
50
+
51
+ const body: unknown = await req.json();
52
+ if (typeof body !== "object" || body === null) {
53
+ return jsonFail("Body must be a JSON object", { status: 400 });
54
+ }
55
+
56
+ // Validate with Zod (imported lazily to keep this route light)
57
+ const { userUpdateSchema } = await import("@/lib/validation/forms");
58
+ try {
59
+ const parsed = userUpdateSchema.parse(body);
60
+
61
+ // Build Prisma-compatible update object (avoid `as any` + incremental mutation)
62
+ const data = {
63
+ ...(parsed.name !== undefined
64
+ ? { name: parsed.name === "" ? null : parsed.name }
65
+ : {}),
66
+ ...(parsed.email !== undefined ? { email: parsed.email } : {}),
67
+ ...(parsed.image !== undefined
68
+ ? { image: parsed.image === "" ? null : parsed.image }
69
+ : {}),
70
+ ...(parsed.password !== undefined
71
+ ? {
72
+ password: await (async () => {
73
+ const { hashPassword } = await import("@/lib/hash");
74
+ // `password` is present in this branch; help TS narrow from `string | undefined`.
75
+ return hashPassword(parsed.password!);
76
+ })(),
77
+ }
78
+ : {}),
79
+ ...(parsed.emailVerified !== undefined
80
+ ? { emailVerified: parsed.emailVerified }
81
+ : {}),
82
+ } satisfies Parameters<typeof prisma.user.update>[0]["data"];
83
+
84
+ const updated = await prisma.user.update({
85
+ where: { id },
86
+ data,
87
+ });
88
+
89
+ return jsonOk(updated, { status: 200, message: "User updated" });
90
+ } catch (err) {
91
+ // Zod errors
92
+ if (err && typeof err === "object" && "issues" in (err as any)) {
93
+ const { jsonFromZod } = await import("@/lib/server/result");
94
+ return jsonFromZod(err as any, {
95
+ status: 400,
96
+ message: "Validation failed",
97
+ });
98
+ }
99
+ throw err;
100
+ }
101
+ } catch (e) {
102
+ console.error("PUT /api/users/[id] error:", e);
103
+ if (hasErrorCode(e, "P2025")) {
104
+ return jsonFail("Not found", { status: 404 });
105
+ }
106
+ return jsonFail("Failed to update user", { status: 500 });
107
+ }
108
+ }
109
+
110
+ export async function DELETE(_req: NextRequest, { params }: RouteContext) {
111
+ try {
112
+ const { requireAdminApi } = await import("@/lib/auth-helpers");
113
+ const session = await requireAdminApi();
114
+ if (!session) return jsonFail("Forbidden", { status: 403 });
115
+
116
+ const { id } = await params;
117
+
118
+ // Clean up dependent records first to avoid FK violations
119
+ await prisma.$transaction([
120
+ prisma.account.deleteMany({ where: { userId: id } }),
121
+ prisma.session.deleteMany({ where: { userId: id } }),
122
+ prisma.post.deleteMany({ where: { authorId: id } }),
123
+ prisma.user.delete({ where: { id } }),
124
+ ]);
125
+
126
+ return jsonOk({ ok: true }, { status: 200, message: "User deleted" });
127
+ } catch (e) {
128
+ console.error("DELETE /api/users/[id] error:", e);
129
+ if (hasErrorCode(e, "P2025")) {
130
+ return jsonFail("Not found", { status: 404 });
131
+ }
132
+ return jsonFail("Failed to delete user", { status: 500 });
133
+ }
134
+ }
@@ -1,187 +1,186 @@
1
- "use client";
2
-
3
- import React, { useEffect, useState } from "react";
4
- import { useSearchParams, useRouter } from "next/navigation";
5
- import { useForm } from "react-hook-form";
6
- import { zodResolver } from "@hookform/resolvers/zod";
7
- import { resetPasswordSchema } from "@/lib/validation/forms";
8
- import { Input } from "@/components/ui/input";
9
- import { Button } from "@/components/ui/button";
10
- import { Form } from "@/components/ui/form/form";
11
- import { FormField } from "@/components/ui/form/form-field";
12
- import { FormItem } from "@/components/ui/form/form-item";
13
- import { FormLabel } from "@/components/ui/form/form-label";
14
- import { FormControl } from "@/components/ui/form/form-control";
15
- import { FormMessage } from "@/components/ui/form/form-message";
16
- import { toast } from "sonner";
17
- import { signOut } from "next-auth/react";
18
-
19
- export default function ResetPasswordPage() {
20
- // Do not gate rendering on process.env here — rendering must be consistent
21
- // between server and client to avoid hydration mismatches. The API routes
22
- // already enforce the feature guard (returning 404) so we let the client
23
- // always render and surface the API's response.
24
- const searchParams = useSearchParams();
25
- const router = useRouter();
26
- const token = searchParams.get("token") || "";
27
- const [valid, setValid] = useState<boolean | null>(null);
28
-
29
- const methods = useForm({
30
- resolver: zodResolver(resetPasswordSchema) as any,
31
- defaultValues: { token, password: "", confirmPassword: "" },
32
- });
33
- const {
34
- handleSubmit,
35
- control,
36
- formState: { isSubmitting },
37
- } = methods;
38
-
39
- useEffect(() => {
40
- (async () => {
41
- if (!token) return setValid(false);
42
- try {
43
- const res = await fetch(
44
- `/api/auth/reset-password?token=${encodeURIComponent(token)}`,
45
- );
46
- if (res.ok) {
47
- const json = await res.json();
48
- setValid(!!json.valid);
49
- } else {
50
- setValid(false);
51
- }
52
- } catch {
53
- setValid(false);
54
- }
55
- })();
56
- }, [token]);
57
-
58
- const onSubmit = async (data: any) => {
59
- try {
60
- const res = await fetch("/api/auth/reset-password", {
61
- method: "POST",
62
- body: JSON.stringify(data),
63
- headers: { "Content-Type": "application/json" },
64
- });
65
- if (res.ok) {
66
- toast.success("Password updated. You can now sign in.");
67
- try {
68
- // Ensure any existing session is cleared so the login page doesn't
69
- // immediately redirect away. This also avoids confusion during testing.
70
- await signOut({ redirect: false });
71
- } catch (e) {
72
- // ignore signOut failures but log for debugging
73
- // eslint-disable-next-line no-console
74
- console.error("signOut failed:", e);
75
- }
76
- // Poll /api/auth/session until it returns null, or timeout after 2s.
77
- // This avoids the race where signOut is accepted server-side but the
78
- // client still believes it's authenticated due to caching or timing.
79
- const waitForSignOut = async (timeout = 2000, interval = 200) => {
80
- const start = Date.now();
81
- while (Date.now() - start < timeout) {
82
- try {
83
- const r = await fetch("/api/auth/session");
84
- if (r.ok) {
85
- const json = await r.json();
86
- if (!json) {
87
- // session cleared
88
- window.location.href = "/auth/login?signup=1";
89
- return;
90
- }
91
- }
92
- } catch (e) {
93
- // ignore transient fetch errors
94
- }
95
- // eslint-disable-next-line no-await-in-loop
96
- await new Promise((res) => setTimeout(res, interval));
97
- }
98
- // Timeout: navigate anyway to ensure user sees login
99
- window.location.href = "/auth/login?signup=1";
100
- };
101
- waitForSignOut();
102
- } else {
103
- const json = await res.json();
104
- toast.error(json?.message || "Failed to reset password");
105
- }
106
- } catch (e) {
107
- toast.error("Failed to reset password");
108
- }
109
- };
110
-
111
- if (valid === null)
112
- return (
113
- <div className="mx-auto w-full max-w-md pt-6">Checking token...</div>
114
- );
115
- if (valid === false)
116
- return (
117
- <div className="mx-auto w-full max-w-md pt-6">
118
- Invalid or expired token.
119
- </div>
120
- );
121
-
122
- return (
123
- <div className="mx-auto w-full max-w-md pt-6">
124
- <h2 className="text-foreground text-center text-2xl font-bold">
125
- Reset password
126
- </h2>
127
- <p className="text-muted-foreground mt-1 text-center text-sm">
128
- Set a new password for your account.
129
- </p>
130
-
131
- <Form methods={methods}>
132
- <form
133
- onSubmit={handleSubmit(onSubmit)}
134
- className="border-border bg-card space-y-4 rounded-lg border p-6 shadow-sm"
135
- >
136
- <FormField
137
- control={control}
138
- name="password"
139
- render={({ field: f }) => (
140
- <FormItem>
141
- <FormLabel>New password</FormLabel>
142
- <FormControl>
143
- <Input
144
- id="password"
145
- type="password"
146
- placeholder="At least 6 characters"
147
- {...f}
148
- />
149
- </FormControl>
150
- <FormMessage />
151
- </FormItem>
152
- )}
153
- />
154
-
155
- <FormField
156
- control={control}
157
- name="confirmPassword"
158
- render={({ field: f }) => (
159
- <FormItem>
160
- <FormLabel>Confirm password</FormLabel>
161
- <FormControl>
162
- <Input
163
- id="confirmPassword"
164
- type="password"
165
- placeholder="Repeat password"
166
- {...f}
167
- />
168
- </FormControl>
169
- <FormMessage />
170
- </FormItem>
171
- )}
172
- />
173
-
174
- <FormField
175
- control={control}
176
- name="token"
177
- render={({ field: f }) => <input type="hidden" {...f} />}
178
- />
179
-
180
- <Button type="submit" disabled={isSubmitting} className="w-full">
181
- Set password
182
- </Button>
183
- </form>
184
- </Form>
185
- </div>
186
- );
187
- }
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { useSearchParams } from "next/navigation";
5
+ import { useForm } from "react-hook-form";
6
+ import { zodResolver } from "@hookform/resolvers/zod";
7
+ import { resetPasswordSchema } from "@/lib/validation/forms";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Form } from "@/components/ui/form/form";
11
+ import { FormField } from "@/components/ui/form/form-field";
12
+ import { FormItem } from "@/components/ui/form/form-item";
13
+ import { FormLabel } from "@/components/ui/form/form-label";
14
+ import { FormControl } from "@/components/ui/form/form-control";
15
+ import { FormMessage } from "@/components/ui/form/form-message";
16
+ import { toast } from "sonner";
17
+ import { signOut } from "next-auth/react";
18
+
19
+ export default function ResetPasswordPage() {
20
+ // Do not gate rendering on process.env here — rendering must be consistent
21
+ // between server and client to avoid hydration mismatches. The API routes
22
+ // already enforce the feature guard (returning 404) so we let the client
23
+ // always render and surface the API's response.
24
+ const searchParams = useSearchParams();
25
+ const token = searchParams.get("token") || "";
26
+ const [valid, setValid] = useState<boolean | null>(null);
27
+
28
+ type ResetPasswordValues = typeof resetPasswordSchema._type;
29
+
30
+ const methods = useForm<ResetPasswordValues>({
31
+ resolver: zodResolver(resetPasswordSchema),
32
+ defaultValues: { token, password: "", confirmPassword: "" },
33
+ });
34
+ const {
35
+ handleSubmit,
36
+ control,
37
+ formState: { isSubmitting },
38
+ } = methods;
39
+
40
+ useEffect(() => {
41
+ (async () => {
42
+ if (!token) return setValid(false);
43
+ try {
44
+ const res = await fetch(
45
+ `/api/auth/reset-password?token=${encodeURIComponent(token)}`,
46
+ );
47
+ if (res.ok) {
48
+ const json = await res.json();
49
+ setValid(!!json.valid);
50
+ } else {
51
+ setValid(false);
52
+ }
53
+ } catch {
54
+ setValid(false);
55
+ }
56
+ })();
57
+ }, [token]);
58
+
59
+ const onSubmit = async (data: ResetPasswordValues) => {
60
+ try {
61
+ const res = await fetch("/api/auth/reset-password", {
62
+ method: "POST",
63
+ body: JSON.stringify(data),
64
+ headers: { "Content-Type": "application/json" },
65
+ });
66
+ if (res.ok) {
67
+ toast.success("Password updated. You can now sign in.");
68
+ try {
69
+ // Ensure any existing session is cleared so the login page doesn't
70
+ // immediately redirect away. This also avoids confusion during testing.
71
+ await signOut({ redirect: false });
72
+ } catch (e: unknown) {
73
+ // ignore signOut failures but log for debugging
74
+ console.error("signOut failed:", e);
75
+ }
76
+ // Poll /api/auth/session until it returns null, or timeout after 2s.
77
+ // This avoids the race where signOut is accepted server-side but the
78
+ // client still believes it's authenticated due to caching or timing.
79
+ const waitForSignOut = async (timeout = 2000, interval = 200) => {
80
+ const start = Date.now();
81
+ while (Date.now() - start < timeout) {
82
+ try {
83
+ const r = await fetch("/api/auth/session");
84
+ if (r.ok) {
85
+ const json = await r.json();
86
+ if (!json) {
87
+ // session cleared
88
+ window.location.href = "/auth/login?signup=1";
89
+ return;
90
+ }
91
+ }
92
+ } catch {
93
+ // ignore transient fetch errors
94
+ }
95
+ await new Promise((res) => setTimeout(res, interval));
96
+ }
97
+ // Timeout: navigate anyway to ensure user sees login
98
+ window.location.href = "/auth/login?signup=1";
99
+ };
100
+ waitForSignOut();
101
+ } else {
102
+ const json = await res.json();
103
+ toast.error(json?.message || "Failed to reset password");
104
+ }
105
+ } catch {
106
+ toast.error("Failed to reset password");
107
+ }
108
+ };
109
+
110
+ if (valid === null)
111
+ return (
112
+ <div className="mx-auto w-full max-w-md pt-6">Checking token...</div>
113
+ );
114
+ if (valid === false)
115
+ return (
116
+ <div className="mx-auto w-full max-w-md pt-6">
117
+ Invalid or expired token.
118
+ </div>
119
+ );
120
+
121
+ return (
122
+ <div className="mx-auto w-full max-w-md pt-6">
123
+ <h2 className="text-foreground text-center text-2xl font-bold">
124
+ Reset password
125
+ </h2>
126
+ <p className="text-muted-foreground mt-1 text-center text-sm">
127
+ Set a new password for your account.
128
+ </p>
129
+
130
+ <Form methods={methods}>
131
+ <form
132
+ onSubmit={handleSubmit(onSubmit)}
133
+ className="border-border bg-card space-y-4 rounded-lg border p-6 shadow-sm"
134
+ >
135
+ <FormField
136
+ control={control}
137
+ name="password"
138
+ render={({ field: f }) => (
139
+ <FormItem>
140
+ <FormLabel>New password</FormLabel>
141
+ <FormControl>
142
+ <Input
143
+ id="password"
144
+ type="password"
145
+ placeholder="At least 6 characters"
146
+ {...f}
147
+ />
148
+ </FormControl>
149
+ <FormMessage />
150
+ </FormItem>
151
+ )}
152
+ />
153
+
154
+ <FormField
155
+ control={control}
156
+ name="confirmPassword"
157
+ render={({ field: f }) => (
158
+ <FormItem>
159
+ <FormLabel>Confirm password</FormLabel>
160
+ <FormControl>
161
+ <Input
162
+ id="confirmPassword"
163
+ type="password"
164
+ placeholder="Repeat password"
165
+ {...f}
166
+ />
167
+ </FormControl>
168
+ <FormMessage />
169
+ </FormItem>
170
+ )}
171
+ />
172
+
173
+ <FormField
174
+ control={control}
175
+ name="token"
176
+ render={({ field: f }) => <input type="hidden" {...f} />}
177
+ />
178
+
179
+ <Button type="submit" disabled={isSubmitting} className="w-full">
180
+ Set password
181
+ </Button>
182
+ </form>
183
+ </Form>
184
+ </div>
185
+ );
186
+ }