@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.
- package/package.json +2 -3
- package/src/index.js +10 -3
- package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
- package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +104 -0
- package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
- package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
- package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
- package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
- package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
- package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
- package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
- package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
- package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
- package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
- package/templates/base-project/packages/db/src/queries.js +13 -0
- package/templates/base-project/packages/db/src/schemas.js +16 -6
- package/templates/jobs/src/definitions.js +1 -0
- package/templates/worker/src/handlers/email-job-validation.js +13 -0
- package/templates/worker/src/handlers/email.js +39 -0
- package/templates/worker/src/handlers/index.js +2 -0
package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
52
|
-
|
|
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() } },
|