@usequark/quark-create-app 1.25.15 → 1.26.0

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 (25) hide show
  1. package/package.json +2 -3
  2. package/src/index.js +10 -3
  3. package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
  4. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
  5. package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +104 -0
  6. package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
  7. package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
  8. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
  9. package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
  10. package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
  11. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
  12. package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
  13. package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
  14. package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
  15. package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
  16. package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
  17. package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
  18. package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
  19. package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
  20. package/templates/base-project/packages/db/src/queries.js +13 -0
  21. package/templates/base-project/packages/db/src/schemas.js +16 -6
  22. package/templates/jobs/src/definitions.js +1 -0
  23. package/templates/worker/src/handlers/email-job-validation.js +13 -0
  24. package/templates/worker/src/handlers/email.js +39 -0
  25. package/templates/worker/src/handlers/index.js +2 -0
@@ -0,0 +1,280 @@
1
+ "use client";
2
+
3
+ import { clearCsrfToken, getCsrfToken } from "@usequark/quark-core/csrf-client";
4
+ import {
5
+ Button,
6
+ ErrorBanner,
7
+ Label,
8
+ PasswordInput,
9
+ QuarkLogo,
10
+ } from "@usequark/quark-ui";
11
+ import Link from "next/link";
12
+ import { useSearchParams } from "next/navigation";
13
+ import { Suspense, useEffect, useState } from "react";
14
+
15
+ const STRENGTH_LABELS = ["", "Weak", "Fair", "Good", "Strong"];
16
+ const STRENGTH_COLORS = [
17
+ "",
18
+ "bg-danger",
19
+ "bg-warning",
20
+ "bg-primary",
21
+ "bg-success",
22
+ ];
23
+
24
+ function getPasswordStrength(value) {
25
+ if (!value) return 0;
26
+ let score = 0;
27
+ if (value.length >= 8) score++;
28
+ if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score++;
29
+ if (/\d/.test(value)) score++;
30
+ if (/[^a-zA-Z0-9]/.test(value)) score++;
31
+ return score;
32
+ }
33
+
34
+ function validatePassword(value) {
35
+ if (getPasswordStrength(value) < 3) {
36
+ return "Must be 8+ characters with uppercase, lowercase, and a number.";
37
+ }
38
+ return "";
39
+ }
40
+
41
+ export default function ResetPasswordPageClient() {
42
+ return (
43
+ <Suspense>
44
+ <ResetPasswordForm />
45
+ </Suspense>
46
+ );
47
+ }
48
+
49
+ function ResetPasswordForm() {
50
+ const searchParams = useSearchParams();
51
+ const token = searchParams.get("token") || "";
52
+
53
+ // `invalid` means the link is unusable (missing, expired, consumed).
54
+ // `error` means the validity check itself failed and the form stays down
55
+ // rather than letting a user type a new password into a form that might not
56
+ // work.
57
+ const [linkState, setLinkState] = useState("checking");
58
+ const [password, setPassword] = useState("");
59
+ const [confirmPassword, setConfirmPassword] = useState("");
60
+ const [passwordError, setPasswordError] = useState("");
61
+ const [error, setError] = useState("");
62
+ const [loading, setLoading] = useState(false);
63
+ const [done, setDone] = useState(false);
64
+ const strength = getPasswordStrength(password);
65
+
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+
69
+ void (async () => {
70
+ try {
71
+ const res = await fetch(
72
+ `/api/auth/reset-password?token=${encodeURIComponent(token)}`,
73
+ );
74
+ if (!cancelled) {
75
+ setLinkState(res.ok ? "ready" : "invalid");
76
+ }
77
+ } catch {
78
+ if (!cancelled) setLinkState("error");
79
+ }
80
+ })();
81
+
82
+ return () => {
83
+ cancelled = true;
84
+ };
85
+ }, [token]);
86
+
87
+ async function handleSubmit(e) {
88
+ e.preventDefault();
89
+ setError("");
90
+
91
+ const pwdErr = validatePassword(password);
92
+ if (pwdErr) {
93
+ setPasswordError(pwdErr);
94
+ return;
95
+ }
96
+
97
+ if (password !== confirmPassword) {
98
+ setPasswordError("Passwords do not match.");
99
+ return;
100
+ }
101
+
102
+ setPasswordError("");
103
+ setLoading(true);
104
+
105
+ try {
106
+ let res = await postReset(await getCsrfToken());
107
+
108
+ if (res.status === 401) {
109
+ clearCsrfToken();
110
+ res = await postReset(await getCsrfToken());
111
+ }
112
+
113
+ if (!res.ok) {
114
+ const body = await res.json().catch(() => ({}));
115
+ setError(body.message || "Reset failed. Try again.");
116
+ setLoading(false);
117
+ return;
118
+ }
119
+
120
+ setDone(true);
121
+ setLoading(false);
122
+ } catch {
123
+ setError("Could not reach the server. Try again.");
124
+ setLoading(false);
125
+ }
126
+ }
127
+
128
+ async function postReset(csrfToken) {
129
+ return await fetch("/api/auth/reset-password", {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Type": "application/json",
133
+ "X-CSRF-Token": csrfToken,
134
+ },
135
+ body: JSON.stringify({ token, password }),
136
+ });
137
+ }
138
+
139
+ return (
140
+ <div className="quark-auth-layout">
141
+ {/* Authority / branding panel */}
142
+ <div className="quark-auth-brand">
143
+ <Link href="/" aria-label="Go to home">
144
+ <QuarkLogo size={64} className="mb-8" />
145
+ </Link>
146
+ <p className="text-xs uppercase tracking-widest text-text-faint mb-3">
147
+ Account recovery
148
+ </p>
149
+ <h2 className="text-3xl font-bold text-text leading-tight mb-4">
150
+ Choose a new password.
151
+ </h2>
152
+ <p className="text-sm text-text-muted leading-relaxed">
153
+ Once set, every existing session is signed out — including any session
154
+ the old password was signed in from.
155
+ </p>
156
+ </div>
157
+
158
+ {/* Form panel */}
159
+ <div className="quark-auth-form">
160
+ {/* Mobile-only logo */}
161
+ <div className="flex md:hidden mb-8">
162
+ <Link href="/" aria-label="Go to home">
163
+ <QuarkLogo size={44} />
164
+ </Link>
165
+ </div>
166
+
167
+ <div className="quark-auth-panel w-full max-w-sm">
168
+ <div className="mb-8">
169
+ <h1 className="text-2xl font-bold tracking-tight text-text">
170
+ Reset password
171
+ </h1>
172
+ </div>
173
+
174
+ <ErrorBanner message={error} />
175
+
176
+ {linkState === "checking" ? (
177
+ <p className="text-sm text-text-muted leading-relaxed mb-8">
178
+ Checking your link…
179
+ </p>
180
+ ) : null}
181
+
182
+ {linkState === "invalid" || linkState === "error" ? (
183
+ <>
184
+ <p className="text-sm text-text-muted leading-relaxed mb-8">
185
+ This reset link is invalid or has expired. Request a new one
186
+ from the forgot-password page.
187
+ </p>
188
+ <Link
189
+ href="/auth/forgot-password"
190
+ className="text-xs text-primary hover:opacity-75 transition-opacity duration-200 linear uppercase tracking-widest"
191
+ >
192
+ Request a new link
193
+ </Link>
194
+ </>
195
+ ) : null}
196
+
197
+ {linkState === "ready" && !done ? (
198
+ <form onSubmit={handleSubmit} className="space-y-5">
199
+ <div className="space-y-2">
200
+ <Label htmlFor="password">New password</Label>
201
+ <PasswordInput
202
+ id="password"
203
+ autoComplete="new-password"
204
+ required
205
+ value={password}
206
+ onChange={(e) => {
207
+ setPassword(e.target.value);
208
+ if (passwordError)
209
+ setPasswordError(validatePassword(e.target.value));
210
+ }}
211
+ />
212
+ {password && (
213
+ <div className="space-y-1">
214
+ <div className="flex gap-0.5">
215
+ {[1, 2, 3, 4].map((seg) => (
216
+ <div
217
+ key={seg}
218
+ className={`h-0.5 flex-1 transition-colors duration-200 linear ${strength >= seg ? STRENGTH_COLORS[strength] : "bg-border"}`}
219
+ />
220
+ ))}
221
+ </div>
222
+ <p className="text-xs text-text-faint uppercase tracking-widest">
223
+ {STRENGTH_LABELS[strength]}
224
+ </p>
225
+ </div>
226
+ )}
227
+ </div>
228
+
229
+ <div className="space-y-2">
230
+ <Label htmlFor="confirm-password">Confirm new password</Label>
231
+ <PasswordInput
232
+ id="confirm-password"
233
+ autoComplete="new-password"
234
+ required
235
+ value={confirmPassword}
236
+ onChange={(e) => {
237
+ setConfirmPassword(e.target.value);
238
+ if (passwordError) setPasswordError("");
239
+ }}
240
+ />
241
+ {passwordError ? (
242
+ <p className="text-xs text-danger uppercase tracking-widest">
243
+ {passwordError}
244
+ </p>
245
+ ) : null}
246
+ </div>
247
+
248
+ <Button
249
+ type="submit"
250
+ variant="primary"
251
+ className="w-full mt-2"
252
+ disabled={loading}
253
+ >
254
+ {loading ? "Resetting…" : "Set new password →"}
255
+ </Button>
256
+ </form>
257
+ ) : null}
258
+
259
+ {done ? (
260
+ <>
261
+ <p
262
+ role="status"
263
+ className="text-sm text-text-muted leading-relaxed mb-8"
264
+ >
265
+ Your password has been updated. All previous sessions have been
266
+ signed out.
267
+ </p>
268
+ <Link
269
+ href="/auth/signin"
270
+ className="text-xs text-primary hover:opacity-75 transition-opacity duration-200 linear uppercase tracking-widest"
271
+ >
272
+ Sign in with your new password →
273
+ </Link>
274
+ </>
275
+ ) : null}
276
+ </div>
277
+ </div>
278
+ </div>
279
+ );
280
+ }
@@ -0,0 +1,9 @@
1
+ import ResetPasswordPageClient from "./ResetPasswordPageClient";
2
+
3
+ export const metadata = {
4
+ title: "Reset Password",
5
+ };
6
+
7
+ export default function ResetPasswordPage() {
8
+ return <ResetPasswordPageClient />;
9
+ }
@@ -37,6 +37,7 @@ function SignInForm({ allowSignup }) {
37
37
  const searchParams = useSearchParams();
38
38
  const callbackUrl = searchParams.get("callbackUrl") || "/";
39
39
  const errorCode = searchParams.get("error");
40
+ const verified = searchParams.get("verified");
40
41
 
41
42
  const [email, setEmail] = useState("");
42
43
  const [password, setPassword] = useState("");
@@ -46,6 +47,18 @@ function SignInForm({ allowSignup }) {
46
47
  const [error, setError] = useState(
47
48
  errorCode ? (ERROR_MESSAGES[errorCode] ?? ERROR_MESSAGES.Default) : "",
48
49
  );
50
+ // `/api/auth/verify-email` redirects here with ?verified=1|invalid|error.
51
+ // A notice is the difference between "why can I sign in now?" and "did my
52
+ // email even work?" for a user arriving from their mailbox.
53
+ const [notice] = useState(
54
+ verified === "1"
55
+ ? "Your email address is verified. You can sign in."
56
+ : verified === "invalid"
57
+ ? "That verification link is invalid or has expired."
58
+ : verified === "error"
59
+ ? "Verification failed. Try again from the link in your email."
60
+ : "",
61
+ );
49
62
 
50
63
  useEffect(() => {
51
64
  let cancelled = false;
@@ -131,6 +144,15 @@ function SignInForm({ allowSignup }) {
131
144
 
132
145
  <ErrorBanner message={error} />
133
146
 
147
+ {notice ? (
148
+ <div
149
+ role="status"
150
+ className="border border-success/40 bg-success-muted px-4 py-3 text-sm text-success mb-8"
151
+ >
152
+ {notice}
153
+ </div>
154
+ ) : null}
155
+
134
156
  <form onSubmit={handleSubmit} className="space-y-5">
135
157
  <div className="space-y-2">
136
158
  <Label htmlFor="email">Email</Label>
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Queue name and enqueue mechanics shared by every transactional email the web
3
+ * service sends.
4
+ *
5
+ * The queue module is imported dynamically from inside this file, so callers
6
+ * that import a thin wrapper statically never pull BullMQ into their module
7
+ * graph. That matters most for `/api/auth/*` routes, which are reachable on the
8
+ * web service: a module-scope `createQueue` import both loaded BullMQ at
9
+ * startup and pinned a Redis connection to the web process for as long as the
10
+ * process lived.
11
+ *
12
+ * It also gives each route a static boundary to substitute in tests. `mock.module`
13
+ * intercepts a dynamic import only when the importing module registered the
14
+ * mock, so a dynamic import made by the route itself cannot be mocked from the
15
+ * route's test. A static import of a wrapper module can.
16
+ *
17
+ * The queue name is duplicated here rather than imported from `@<scope>/jobs`
18
+ * on purpose: `jobs` is an optional scaffold package, and importing it from the
19
+ * web module broke the build of every project that declined it. The
20
+ * `packages/cli` scaffold-drift test fails when this copy diverges from the
21
+ * jobs definitions.
22
+ */
23
+ export const EMAIL_QUEUE = "email-queue";
24
+
25
+ /**
26
+ * Loads the queue module on demand.
27
+ *
28
+ * Kept as a named binding rather than an inline `import()` so it can be
29
+ * substituted — see `loadQueue` on {@link enqueueEmailJob}.
30
+ *
31
+ * @returns {Promise<typeof import("@usequark/quark-core/queue")>}
32
+ */
33
+ function loadQueueModule() {
34
+ return import("@usequark/quark-core/queue");
35
+ }
36
+
37
+ /**
38
+ * Enqueues an email job, closing the queue either way.
39
+ *
40
+ * @param {string} jobName - Job name from the jobs package (e.g.
41
+ * `SEND_WELCOME_EMAIL`). The worker rejects an unknown name, so this is not
42
+ * validated client-side — a helper that silently accepted a typo would hide
43
+ * the rejection in a log line.
44
+ * @param {object} data - Job payload, validated by the worker's handler.
45
+ * @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
46
+ * How to obtain the queue module. Defaults to the dynamic import above;
47
+ * overridden in tests.
48
+ * @returns {Promise<void>}
49
+ * @throws Whatever `createQueue` or `add` throws. The queue is closed either
50
+ * way, so a caller that swallows the error still leaves no connection behind.
51
+ */
52
+ export async function enqueueEmailJob(
53
+ jobName,
54
+ data,
55
+ loadQueue = loadQueueModule,
56
+ ) {
57
+ const { createQueue } = await loadQueue();
58
+ const queue = createQueue(EMAIL_QUEUE);
59
+
60
+ try {
61
+ await queue.add(jobName, data);
62
+ } finally {
63
+ // Never let a failing close mask the enqueue outcome — the caller only
64
+ // needs to know whether the job reached the queue.
65
+ await queue.close().catch(() => {});
66
+ }
67
+ }
@@ -0,0 +1,33 @@
1
+ import { enqueueEmailJob } from "./enqueue-email-job.js";
2
+
3
+ /**
4
+ * Job name for the password-reset message.
5
+ *
6
+ * Mirrors `JOB_NAMES.SEND_RESET_PASSWORD_EMAIL` in the optional `@<scope>/jobs`
7
+ * package (see `enqueue-email-job.js` for why it is duplicated). The
8
+ * `packages/cli` scaffold-drift test fails when the two copies diverge.
9
+ *
10
+ * Until this helper existed, no scaffolded route enqueued this job: the worker
11
+ * handler and the email template shipped, but nothing ever called them. The
12
+ * forgot-password route is the caller.
13
+ */
14
+ export const SEND_RESET_PASSWORD_EMAIL = "send-reset-password-email";
15
+
16
+ /**
17
+ * Enqueues the password-reset email for a user who requested one.
18
+ *
19
+ * @param {string} userId - The user to email. The worker resolves the address
20
+ * from this id, so the reset link cannot be sent to an attacker-chosen
21
+ * mailbox.
22
+ * @param {string} resetUrl - Absolute, single-use reset link.
23
+ * @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
24
+ * Queue-module loader, overridden in tests. See {@link enqueueEmailJob}.
25
+ * @returns {Promise<void>}
26
+ */
27
+ export async function enqueueResetPasswordEmail(userId, resetUrl, loadQueue) {
28
+ return enqueueEmailJob(
29
+ SEND_RESET_PASSWORD_EMAIL,
30
+ { userId, resetUrl },
31
+ loadQueue,
32
+ );
33
+ }
@@ -0,0 +1,25 @@
1
+ import { enqueueEmailJob } from "./enqueue-email-job.js";
2
+
3
+ /**
4
+ * Job name for the email-verification message.
5
+ *
6
+ * Mirrors `JOB_NAMES.SEND_VERIFY_EMAIL` in the optional `@<scope>/jobs`
7
+ * package (see `enqueue-email-job.js` for why it is duplicated). The
8
+ * `packages/cli` scaffold-drift test fails when the two copies diverge.
9
+ */
10
+ export const SEND_VERIFY_EMAIL = "send-verify-email";
11
+
12
+ /**
13
+ * Enqueues the verification email a new account needs to confirm its address.
14
+ *
15
+ * @param {string} userId - The user whose address is being confirmed. The worker
16
+ * resolves the address from this id rather than trusting the caller, so an
17
+ * attacker cannot enqueue a verification mail to an address they do not own.
18
+ * @param {string} verifyUrl - Absolute, single-use confirmation link.
19
+ * @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
20
+ * Queue-module loader, overridden in tests. See {@link enqueueEmailJob}.
21
+ * @returns {Promise<void>}
22
+ */
23
+ export async function enqueueVerificationEmail(userId, verifyUrl, loadQueue) {
24
+ return enqueueEmailJob(SEND_VERIFY_EMAIL, { userId, verifyUrl }, loadQueue);
25
+ }
@@ -1,62 +1,27 @@
1
- /**
2
- * Queue and job name for the signup welcome email.
3
- *
4
- * These mirror `JOB_QUEUES.EMAIL` and `JOB_NAMES.SEND_WELCOME_EMAIL` in the
5
- * `@<scope>/jobs` package — an *optional* scaffold package. Importing them from
6
- * there puts `@<scope>/jobs` in the web module graph of every project, and a
7
- * project that declines `jobs` has no such dependency to resolve: the web app
8
- * fails to build before any test can run. The strings are duplicated here on
9
- * purpose; a `packages/cli` test fails when the two copies diverge.
10
- */
11
- export const EMAIL_QUEUE = "email-queue";
12
- export const SEND_WELCOME_EMAIL = "send-welcome-email";
1
+ import { enqueueEmailJob } from "./enqueue-email-job.js";
2
+
3
+ // Re-exported so callers that already import the welcome helper keep their
4
+ // import site, and so its test still asserts the queue name in one place.
5
+ export { EMAIL_QUEUE } from "./enqueue-email-job.js";
13
6
 
14
7
  /**
15
- * Loads the queue module on demand.
16
- *
17
- * Kept as a named binding rather than an inline `import()` so it can be
18
- * substituted — see `loadQueue` on {@link enqueueWelcomeEmail}.
8
+ * Job name for the signup welcome email.
19
9
  *
20
- * @returns {Promise<typeof import("@usequark/quark-core/queue")>}
10
+ * Mirrors `JOB_NAMES.SEND_WELCOME_EMAIL` in the optional `@<scope>/jobs`
11
+ * package (see `enqueue-email-job.js` for why it is duplicated). The
12
+ * `packages/cli` scaffold-drift test fails when the two copies diverge.
21
13
  */
22
- function loadQueueModule() {
23
- return import("@usequark/quark-core/queue");
24
- }
14
+ export const SEND_WELCOME_EMAIL = "send-welcome-email";
25
15
 
26
16
  /**
27
17
  * Enqueues the post-signup welcome email for a newly created user.
28
18
  *
29
- * This lives in its own module for two reasons.
30
- *
31
- * The queue module is imported dynamically from inside this file, so callers
32
- * that import *this* statically never pull BullMQ into their module graph. That
33
- * matters most for `/api/auth/register`, which is reachable on the web service:
34
- * a module-scope `createQueue` import both loaded BullMQ at startup and pinned a
35
- * Redis connection to the web process for as long as the process lived.
36
- *
37
- * It also gives the route a static boundary to substitute in tests. `mock.module`
38
- * intercepts a dynamic import only when the importing module registered the mock,
39
- * so a dynamic import made by the route itself cannot be mocked from the route's
40
- * test. A static import of this module can.
41
- *
42
19
  * @param {string} userId - The user to welcome. The worker rejects a job with
43
20
  * no userId, so this is required rather than defaulted.
44
21
  * @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
45
- * How to obtain the queue module. Defaults to the dynamic import above;
46
- * overridden in tests.
22
+ * Queue-module loader, overridden in tests. See {@link enqueueEmailJob}.
47
23
  * @returns {Promise<void>}
48
- * @throws Whatever `createQueue` or `add` throws. The queue is closed either
49
- * way, so a caller that swallows the error still leaves no connection behind.
50
24
  */
51
- export async function enqueueWelcomeEmail(userId, loadQueue = loadQueueModule) {
52
- const { createQueue } = await loadQueue();
53
- const queue = createQueue(EMAIL_QUEUE);
54
-
55
- try {
56
- await queue.add(SEND_WELCOME_EMAIL, { userId });
57
- } finally {
58
- // Never let a failing close mask the enqueue outcome — the caller only
59
- // needs to know whether the job reached the queue.
60
- await queue.close().catch(() => {});
61
- }
25
+ export async function enqueueWelcomeEmail(userId, loadQueue) {
26
+ return enqueueEmailJob(SEND_WELCOME_EMAIL, { userId }, loadQueue);
62
27
  }
@@ -0,0 +1,52 @@
1
+ import { generateToken, hashToken } from "@usequark/quark-core/auth";
2
+ import { verificationToken } from "@usequark/quark-db";
3
+
4
+ /**
5
+ * Lifetime of a password-reset link.
6
+ *
7
+ * An hour is enough for a mail to arrive and a person to act on it, and short
8
+ * enough that a link found later in a mailbox archive is already dead. The
9
+ * reset email quotes the same window; changing one without the other would make
10
+ * the email lie.
11
+ */
12
+ export const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
13
+
14
+ /**
15
+ * Lifetime of an email-verification link.
16
+ *
17
+ * Longer than a reset: an address is confirmed on the user's own schedule, and
18
+ * nothing is ambiently at risk while the link lives — redeeming it only sets
19
+ * the verified flag, it does not grant a session.
20
+ */
21
+ export const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
22
+
23
+ /**
24
+ * Issues a single-use verification token for an identifier and returns the raw
25
+ * value to embed in the emailed link.
26
+ *
27
+ * The stored row holds only the SHA-256 digest (`hashToken`), never the value
28
+ * the link carries: a leaked database row must not be redeemable. Resolution
29
+ * hashes the presented token before looking it up, so the two always meet.
30
+ *
31
+ * Any outstanding token for the identifier is deleted first. A user who clicks
32
+ * "resend" twice should not leave two working links behind — the newest request
33
+ * is the one they are looking at, and an old email in a shared inbox should
34
+ * stop working the moment a replacement is issued.
35
+ *
36
+ * @param {string} identifier - The user's email address (the `VerificationToken`
37
+ * identifier column).
38
+ * @param {number} ttlMs - Token lifetime in milliseconds.
39
+ * @returns {Promise<string>} The raw token to place in the link. Not stored.
40
+ */
41
+ export async function issueVerificationToken(identifier, ttlMs) {
42
+ await verificationToken.deleteByIdentifier(identifier);
43
+
44
+ const token = generateToken();
45
+ await verificationToken.create({
46
+ identifier,
47
+ token: hashToken(token),
48
+ expires: new Date(Date.now() + ttlMs),
49
+ });
50
+
51
+ return token;
52
+ }
@@ -7,6 +7,13 @@ const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
7
7
  // redirects to the sign-in page — nothing to brute force.
8
8
  "/api/auth/callback/credentials",
9
9
  "/api/auth/register",
10
+ // Password recovery surfaces are credential-adjacent and stay strict:
11
+ // `forgot-password` is an email-sending surface (5 per 15 minutes per
12
+ // IP:path stops an attacker from flooding one mailbox), and
13
+ // `reset-password` is a token-redeeming surface (the same budget limits
14
+ // grinding through stolen or guessed links).
15
+ "/api/auth/forgot-password",
16
+ "/api/auth/reset-password",
10
17
  ]);
11
18
 
12
19
  /**
@@ -0,0 +1,24 @@
1
+ import { getAppUrl } from "@usequark/quark-config";
2
+ import { enqueueResetPasswordEmail } from "./enqueue-reset-password-email.js";
3
+ import {
4
+ issueVerificationToken,
5
+ RESET_TOKEN_TTL_MS,
6
+ } from "./issue-verification-token.js";
7
+
8
+ /**
9
+ * Issues a password-reset link for an account and enqueues the email that
10
+ * carries it.
11
+ *
12
+ * Kept out of the forgot-password route for the same reason the verification
13
+ * equivalent is kept out of the register route: one place that defines how the
14
+ * link is built, and one test for that definition.
15
+ *
16
+ * @param {{ id: string, email: string }} account - The account that requested a reset.
17
+ * @returns {Promise<void>}
18
+ */
19
+ export async function sendResetPasswordEmail(account) {
20
+ const token = await issueVerificationToken(account.email, RESET_TOKEN_TTL_MS);
21
+ const resetUrl = `${getAppUrl()}/auth/reset-password?token=${token}`;
22
+
23
+ await enqueueResetPasswordEmail(account.id, resetUrl);
24
+ }
@@ -0,0 +1,28 @@
1
+ import { getAppUrl } from "@usequark/quark-config";
2
+ import { enqueueVerificationEmail } from "./enqueue-verification-email.js";
3
+ import {
4
+ issueVerificationToken,
5
+ VERIFY_TOKEN_TTL_MS,
6
+ } from "./issue-verification-token.js";
7
+
8
+ /**
9
+ * Issues a verification link for an account and enqueues the email that carries
10
+ * it.
11
+ *
12
+ * Kept out of the register route so the route reads as "create the user, then
13
+ * hand off" and so this sequence has one test that does not have to stand up
14
+ * the whole registration path. The route treats any failure here as
15
+ * non-fatal — the account exists either way.
16
+ *
17
+ * @param {{ id: string, email: string }} account - The freshly created user.
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function sendVerificationEmail(account) {
21
+ const token = await issueVerificationToken(
22
+ account.email,
23
+ VERIFY_TOKEN_TTL_MS,
24
+ );
25
+ const verifyUrl = `${getAppUrl()}/api/auth/verify-email?token=${token}`;
26
+
27
+ await enqueueVerificationEmail(account.id, verifyUrl);
28
+ }
@@ -197,6 +197,19 @@ export const verificationToken = {
197
197
  where: { identifier, token },
198
198
  });
199
199
  },
200
+ /**
201
+ * Removes every token for an identifier.
202
+ *
203
+ * A verification or reset flow issues one live token per address: without
204
+ * this, requesting a second link leaves the first valid too, so an old email
205
+ * keeps working after the user asked for a new one. Called before issuing a
206
+ * replacement, and again once a token has been redeemed.
207
+ */
208
+ deleteByIdentifier: (identifier) => {
209
+ return prisma.verificationToken.deleteMany({
210
+ where: { identifier },
211
+ });
212
+ },
200
213
  deleteExpired: () => {
201
214
  return prisma.verificationToken.deleteMany({
202
215
  where: { expires: { lt: new Date() } },