@usequark/quark-create-app 1.25.16 → 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 +1 -1
- 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/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createLogger,
|
|
3
|
+
validateBody,
|
|
4
|
+
withCsrfProtection,
|
|
5
|
+
} from "@usequark/quark-core/core";
|
|
6
|
+
import { user } from "@usequark/quark-db";
|
|
7
|
+
import { NextResponse } from "next/server";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { sendResetPasswordEmail } from "@/lib/send-reset-password-email";
|
|
10
|
+
import { handleError } from "../../error-handler";
|
|
11
|
+
|
|
12
|
+
const logger = createLogger("auth:forgot-password");
|
|
13
|
+
|
|
14
|
+
const forgotPasswordSchema = z.object({
|
|
15
|
+
email: z.string().email("Invalid email address"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The response for every request, registered address or not.
|
|
20
|
+
*
|
|
21
|
+
* Replying differently for unknown addresses turns this route into an
|
|
22
|
+
* email-enumeration oracle: the difference between "no such user" and "check
|
|
23
|
+
* your inbox" is exactly the information a spammer or a spear-phisher is
|
|
24
|
+
* probing for. The same 200 with the same body is also what the rate limiter
|
|
25
|
+
* needs — a distinguishable error path would let an attacker distinguish
|
|
26
|
+
* "limited" from "not a valid address" from the outside.
|
|
27
|
+
*/
|
|
28
|
+
const NEUTRAL_RESPONSE = {
|
|
29
|
+
message: "If that email address has an account, a reset link is on its way.",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const POST = withCsrfProtection(async (request) => {
|
|
33
|
+
try {
|
|
34
|
+
const { email } = await validateBody(request, forgotPasswordSchema);
|
|
35
|
+
|
|
36
|
+
const account = await user.findByEmail(email);
|
|
37
|
+
|
|
38
|
+
if (account) {
|
|
39
|
+
try {
|
|
40
|
+
await sendResetPasswordEmail(account);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// The address exists; the answer must still be the neutral one. A
|
|
43
|
+
// failure here is operational (token issue or queue down), so it is
|
|
44
|
+
// logged where an operator can see it and deliberately not surfaced
|
|
45
|
+
// to the caller.
|
|
46
|
+
logger.error("Failed to send password reset email", {
|
|
47
|
+
userId: account.id,
|
|
48
|
+
message: error?.message ?? String(error),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return NextResponse.json(NEUTRAL_RESPONSE);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return handleError(error);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { hashPassword } from "@usequark/quark-core/auth";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createLogger,
|
|
4
|
+
validateBody,
|
|
5
|
+
withCsrfProtection,
|
|
6
|
+
} from "@usequark/quark-core/core";
|
|
3
7
|
import { user, userRegisterSchema } from "@usequark/quark-db";
|
|
4
8
|
import { NextResponse } from "next/server";
|
|
5
9
|
import { isSignupEnabled } from "@/lib/auth-signup";
|
|
6
10
|
import { enqueueWelcomeEmail } from "@/lib/enqueue-welcome-email";
|
|
11
|
+
import { sendVerificationEmail } from "@/lib/send-verification-email";
|
|
7
12
|
import { handleError } from "../../error-handler";
|
|
8
13
|
|
|
14
|
+
const logger = createLogger("auth:register");
|
|
15
|
+
|
|
9
16
|
export const POST = withCsrfProtection(async (request) => {
|
|
10
17
|
if (!isSignupEnabled()) {
|
|
11
18
|
return NextResponse.json(
|
|
@@ -51,6 +58,18 @@ export const POST = withCsrfProtection(async (request) => {
|
|
|
51
58
|
// Non-critical - user is created even if email fails to enqueue
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
// Issue and enqueue the email-verification link. Equally non-fatal: the
|
|
62
|
+
// account exists either way, and an unverified address is not (yet) a
|
|
63
|
+
// gated account — it just has no confirmation on file.
|
|
64
|
+
try {
|
|
65
|
+
await sendVerificationEmail(newUser);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger.error("Failed to send verification email", {
|
|
68
|
+
userId: newUser.id,
|
|
69
|
+
message: error?.message ?? String(error),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
return NextResponse.json(safeUser, { status: 201 });
|
|
55
74
|
} catch (error) {
|
|
56
75
|
return handleError(error);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { hashPassword, hashToken } from "@usequark/quark-core/auth";
|
|
2
|
+
import { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
|
|
3
|
+
import { ValidationError } from "@usequark/quark-core/errors";
|
|
4
|
+
import { passwordSchema, user, verificationToken } from "@usequark/quark-db";
|
|
5
|
+
import { NextResponse } from "next/server";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { handleError } from "../../error-handler";
|
|
8
|
+
|
|
9
|
+
const resetPasswordSchema = z.object({
|
|
10
|
+
token: z.string().min(1, "A reset token is required"),
|
|
11
|
+
password: passwordSchema,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The message used for every refused reset: unknown token, expired token, or
|
|
16
|
+
* account gone. An attacker probing a stolen link learns nothing from which
|
|
17
|
+
* wording comes back, because they are the same.
|
|
18
|
+
*/
|
|
19
|
+
const UNUSABLE_LINK_MESSAGE =
|
|
20
|
+
"This reset link is invalid or has expired. Request a new one.";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Loads the token row for a presented token, or null when there is none usable.
|
|
24
|
+
*
|
|
25
|
+
* The row is found by the SHA-256 digest, never by the raw value — that is the
|
|
26
|
+
* entire point of storing the digest in the first place. Expiry is checked
|
|
27
|
+
* here so both GET and POST agree on what "usable" means, and so a consumed
|
|
28
|
+
* (deleted) token is indistinguishable from one that never existed.
|
|
29
|
+
*
|
|
30
|
+
* @param {string | null} rawToken - The token from the link's query string.
|
|
31
|
+
* @returns {Promise<object | null>} The `VerificationToken` row, or null.
|
|
32
|
+
*/
|
|
33
|
+
async function resolveToken(rawToken) {
|
|
34
|
+
if (!rawToken) return null;
|
|
35
|
+
|
|
36
|
+
const record = await verificationToken.findByToken(hashToken(rawToken));
|
|
37
|
+
if (!record) return null;
|
|
38
|
+
if (record.expires.getTime() <= Date.now()) return null;
|
|
39
|
+
|
|
40
|
+
return record;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Answers whether a link is still usable, so the reset page can render the form
|
|
45
|
+
* or an error before the user types a new password into a dead form.
|
|
46
|
+
*
|
|
47
|
+
* GET is safe (no state change) and so is not CSRF-protected; the lookup is a
|
|
48
|
+
* single indexed primary-key read.
|
|
49
|
+
*/
|
|
50
|
+
export async function GET(request) {
|
|
51
|
+
try {
|
|
52
|
+
const token = new URL(request.url).searchParams.get("token");
|
|
53
|
+
const record = await resolveToken(token);
|
|
54
|
+
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ valid: Boolean(record) },
|
|
57
|
+
{ status: record ? 200 : 400 },
|
|
58
|
+
);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return handleError(error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const POST = withCsrfProtection(async (request) => {
|
|
65
|
+
try {
|
|
66
|
+
const { token, password } = await validateBody(
|
|
67
|
+
request,
|
|
68
|
+
resetPasswordSchema,
|
|
69
|
+
);
|
|
70
|
+
const record = await resolveToken(token);
|
|
71
|
+
|
|
72
|
+
if (!record) {
|
|
73
|
+
throw new ValidationError(UNUSABLE_LINK_MESSAGE);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const account = await user.findByEmail(record.identifier);
|
|
77
|
+
if (!account) {
|
|
78
|
+
// The account was deleted after the link was issued. The link is dead
|
|
79
|
+
// either way; burn the token so it cannot be tried a second time.
|
|
80
|
+
await verificationToken.deleteByIdentifier(record.identifier);
|
|
81
|
+
throw new ValidationError(UNUSABLE_LINK_MESSAGE);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const hashedPassword = await hashPassword(password);
|
|
85
|
+
await user.update(account.id, {
|
|
86
|
+
password: hashedPassword,
|
|
87
|
+
// Stamping the change is what reaches every live session (web JWT and
|
|
88
|
+
// mobile refresh token): `validateSessionToken` and the refresh route
|
|
89
|
+
// reject anything issued before this instant, so a password known to
|
|
90
|
+
// an attacker stops working the moment it is replaced.
|
|
91
|
+
passwordChangedAt: new Date(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Single-use: the token dies with the reset it performed. Replaying the
|
|
95
|
+
// same link must not overwrite the new password with another.
|
|
96
|
+
await verificationToken.deleteByIdentifier(account.email);
|
|
97
|
+
|
|
98
|
+
return NextResponse.json({
|
|
99
|
+
message: "Your password has been updated. You can now sign in.",
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return handleError(error);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getAppUrl } from "@usequark/quark-config";
|
|
2
|
+
import { hashToken } from "@usequark/quark-core/auth";
|
|
3
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
4
|
+
import { user, verificationToken } from "@usequark/quark-db";
|
|
5
|
+
import { NextResponse } from "next/server";
|
|
6
|
+
|
|
7
|
+
const logger = createLogger("auth:verify-email");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redeems an emailed verification link.
|
|
11
|
+
*
|
|
12
|
+
* A GET performs the state change because that is the shape of an emailed
|
|
13
|
+
* link — a mail client fetches it by navigation, not by posting JSON. The
|
|
14
|
+
* consequence is a bearer link: whoever holds it can confirm the address, so
|
|
15
|
+
* the same threat model as the reset flow applies — the token is 256 bits of
|
|
16
|
+
* randomness, stored only as a SHA-256 digest, and single-use.
|
|
17
|
+
*
|
|
18
|
+
* Outcomes funnel into the sign-in page through `?verified=`:
|
|
19
|
+
* - `1` - address confirmed (or already confirmed)
|
|
20
|
+
* - `invalid` - no token, expired, consumed, or the account is gone
|
|
21
|
+
* - `error` - something broke server-side; logged, and the caller still
|
|
22
|
+
* lands somewhere useful rather than on a bare 500
|
|
23
|
+
*/
|
|
24
|
+
export async function GET(request) {
|
|
25
|
+
const base = getAppUrl();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const token = new URL(request.url).searchParams.get("token");
|
|
29
|
+
const record = token
|
|
30
|
+
? await verificationToken.findByToken(hashToken(token))
|
|
31
|
+
: null;
|
|
32
|
+
|
|
33
|
+
if (!record || record.expires.getTime() <= Date.now()) {
|
|
34
|
+
return NextResponse.redirect(`${base}/auth/signin?verified=invalid`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const account = await user.findByEmail(record.identifier);
|
|
38
|
+
if (!account) {
|
|
39
|
+
// The account is gone; the confirmation would be for nobody. Burn the
|
|
40
|
+
// token and refuse rather than leaving a live link sitting in an
|
|
41
|
+
// inbox.
|
|
42
|
+
await verificationToken.deleteByIdentifier(record.identifier);
|
|
43
|
+
return NextResponse.redirect(`${base}/auth/signin?verified=invalid`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!account.emailVerified) {
|
|
47
|
+
await user.update(account.id, { emailVerified: new Date() });
|
|
48
|
+
}
|
|
49
|
+
await verificationToken.deleteByIdentifier(account.email);
|
|
50
|
+
|
|
51
|
+
return NextResponse.redirect(`${base}/auth/signin?verified=1`);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger.error("Email verification failed", {
|
|
54
|
+
message: error?.message ?? String(error),
|
|
55
|
+
});
|
|
56
|
+
return NextResponse.redirect(`${base}/auth/signin?verified=error`);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { clearCsrfToken, getCsrfToken } from "@usequark/quark-core/csrf-client";
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
ErrorBanner,
|
|
7
|
+
Input,
|
|
8
|
+
Label,
|
|
9
|
+
QuarkLogo,
|
|
10
|
+
} from "@usequark/quark-ui";
|
|
11
|
+
import Link from "next/link";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The forgot-password form.
|
|
16
|
+
*
|
|
17
|
+
* The POST answers identically whether or not the address exists (see the
|
|
18
|
+
* route's enumeration note), so the client surface shows exactly one outcome:
|
|
19
|
+
* "if that address has an account, a link is on its way." A success state and a
|
|
20
|
+
* network-failure state, and deliberately nothing in between.
|
|
21
|
+
*/
|
|
22
|
+
export default function ForgotPasswordPageClient() {
|
|
23
|
+
const [email, setEmail] = useState("");
|
|
24
|
+
const [loading, setLoading] = useState(false);
|
|
25
|
+
const [error, setError] = useState("");
|
|
26
|
+
const [sent, setSent] = useState(false);
|
|
27
|
+
|
|
28
|
+
async function handleSubmit(e) {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
setError("");
|
|
31
|
+
setLoading(true);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Same pre-authentication CSRF handshake as the register page: fetch a
|
|
35
|
+
// token from GET /api/csrf and send it alongside the matching cookie.
|
|
36
|
+
let res = await postForgotPassword(await getCsrfToken());
|
|
37
|
+
|
|
38
|
+
// A 401 usually means the server rotated the csrf_token cookie out
|
|
39
|
+
// from under the cached token. Retry once with a fresh pair; a second
|
|
40
|
+
// failure is a real failure and falls through to the banner.
|
|
41
|
+
if (res.status === 401) {
|
|
42
|
+
clearCsrfToken();
|
|
43
|
+
res = await postForgotPassword(await getCsrfToken());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!res.ok && res.status !== 429) {
|
|
47
|
+
const body = await res.json().catch(() => ({}));
|
|
48
|
+
setError(body.message || "Something went wrong. Try again.");
|
|
49
|
+
setLoading(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setSent(true);
|
|
54
|
+
setLoading(false);
|
|
55
|
+
} catch {
|
|
56
|
+
setError("Could not reach the server. Try again.");
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function postForgotPassword(csrfToken) {
|
|
62
|
+
return await fetch("/api/auth/forgot-password", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"X-CSRF-Token": csrfToken,
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ email: email.trim() }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="quark-auth-layout">
|
|
74
|
+
{/* Authority / branding panel */}
|
|
75
|
+
<div className="quark-auth-brand">
|
|
76
|
+
<Link href="/" aria-label="Go to home">
|
|
77
|
+
<QuarkLogo size={64} className="mb-8" />
|
|
78
|
+
</Link>
|
|
79
|
+
<p className="text-xs uppercase tracking-widest text-text-faint mb-3">
|
|
80
|
+
Account recovery
|
|
81
|
+
</p>
|
|
82
|
+
<h2 className="text-3xl font-bold text-text leading-tight mb-4">
|
|
83
|
+
Reset your password.
|
|
84
|
+
</h2>
|
|
85
|
+
<p className="text-sm text-text-muted leading-relaxed">
|
|
86
|
+
Enter your email and we will send you a link to recover access.
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Form panel */}
|
|
91
|
+
<div className="quark-auth-form">
|
|
92
|
+
{/* Mobile-only logo */}
|
|
93
|
+
<div className="flex md:hidden mb-8">
|
|
94
|
+
<Link href="/" aria-label="Go to home">
|
|
95
|
+
<QuarkLogo size={44} />
|
|
96
|
+
</Link>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div className="quark-auth-panel w-full max-w-sm">
|
|
100
|
+
<div className="mb-8">
|
|
101
|
+
<h1 className="text-2xl font-bold tracking-tight text-text">
|
|
102
|
+
Forgot password
|
|
103
|
+
</h1>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<ErrorBanner message={error} />
|
|
107
|
+
|
|
108
|
+
{sent ? (
|
|
109
|
+
<>
|
|
110
|
+
<p
|
|
111
|
+
role="status"
|
|
112
|
+
className="text-sm text-text-muted leading-relaxed mb-8"
|
|
113
|
+
>
|
|
114
|
+
If that email address has an account, a reset link is on its
|
|
115
|
+
way. Check your inbox — the link expires after one hour.
|
|
116
|
+
</p>
|
|
117
|
+
<Link
|
|
118
|
+
href="/auth/signin"
|
|
119
|
+
className="text-xs text-text-muted hover:text-text transition-opacity duration-200 linear uppercase tracking-widest"
|
|
120
|
+
>
|
|
121
|
+
← Back to sign in
|
|
122
|
+
</Link>
|
|
123
|
+
</>
|
|
124
|
+
) : (
|
|
125
|
+
<form onSubmit={handleSubmit} className="space-y-5">
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<Label htmlFor="email">Email</Label>
|
|
128
|
+
<Input
|
|
129
|
+
id="email"
|
|
130
|
+
type="email"
|
|
131
|
+
autoComplete="email"
|
|
132
|
+
required
|
|
133
|
+
value={email}
|
|
134
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<Button
|
|
139
|
+
type="submit"
|
|
140
|
+
variant="primary"
|
|
141
|
+
className="w-full mt-2"
|
|
142
|
+
disabled={loading}
|
|
143
|
+
>
|
|
144
|
+
{loading ? "Sending…" : "Send reset link →"}
|
|
145
|
+
</Button>
|
|
146
|
+
|
|
147
|
+
<Link
|
|
148
|
+
href="/auth/signin"
|
|
149
|
+
className="block text-center text-xs text-text-muted hover:text-text transition-opacity duration-200 linear uppercase tracking-widest"
|
|
150
|
+
>
|
|
151
|
+
← Back to sign in
|
|
152
|
+
</Link>
|
|
153
|
+
</form>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -1,58 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import Link from "next/link";
|
|
1
|
+
import ForgotPasswordPageClient from "./ForgotPasswordPageClient";
|
|
3
2
|
|
|
4
3
|
export const metadata = {
|
|
5
4
|
title: "Forgot Password",
|
|
6
5
|
};
|
|
7
6
|
|
|
8
7
|
export default function ForgotPasswordPage() {
|
|
9
|
-
return
|
|
10
|
-
<div className="quark-auth-layout">
|
|
11
|
-
{/* Authority / branding panel */}
|
|
12
|
-
<div className="quark-auth-brand">
|
|
13
|
-
<Link href="/" aria-label="Go to home">
|
|
14
|
-
<QuarkLogo size={64} className="mb-8" />
|
|
15
|
-
</Link>
|
|
16
|
-
<p className="text-xs uppercase tracking-widest text-text-faint mb-3">
|
|
17
|
-
Account recovery
|
|
18
|
-
</p>
|
|
19
|
-
<h2 className="text-3xl font-bold text-text leading-tight mb-4">
|
|
20
|
-
Reset your password.
|
|
21
|
-
</h2>
|
|
22
|
-
<p className="text-sm text-text-muted leading-relaxed">
|
|
23
|
-
Enter your email and we will send you a link to recover access.
|
|
24
|
-
</p>
|
|
25
|
-
</div>
|
|
26
|
-
|
|
27
|
-
{/* Form panel */}
|
|
28
|
-
<div className="quark-auth-form">
|
|
29
|
-
{/* Mobile-only logo */}
|
|
30
|
-
<div className="flex md:hidden mb-8">
|
|
31
|
-
<Link href="/" aria-label="Go to home">
|
|
32
|
-
<QuarkLogo size={44} />
|
|
33
|
-
</Link>
|
|
34
|
-
</div>
|
|
35
|
-
|
|
36
|
-
<div className="quark-auth-panel w-full max-w-sm">
|
|
37
|
-
<div className="mb-8">
|
|
38
|
-
<h1 className="text-2xl font-bold tracking-tight text-text">
|
|
39
|
-
Forgot password
|
|
40
|
-
</h1>
|
|
41
|
-
</div>
|
|
42
|
-
|
|
43
|
-
<p className="text-sm text-text-muted leading-relaxed mb-8">
|
|
44
|
-
Password reset is not available. Please contact support for help
|
|
45
|
-
accessing your account.
|
|
46
|
-
</p>
|
|
47
|
-
|
|
48
|
-
<Link
|
|
49
|
-
href="/auth/signin"
|
|
50
|
-
className="text-xs text-text-muted hover:text-text transition-opacity duration-200 linear uppercase tracking-widest"
|
|
51
|
-
>
|
|
52
|
-
← Back to sign in
|
|
53
|
-
</Link>
|
|
54
|
-
</div>
|
|
55
|
-
</div>
|
|
56
|
-
</div>
|
|
57
|
-
);
|
|
8
|
+
return <ForgotPasswordPageClient />;
|
|
58
9
|
}
|
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() } },
|
|
@@ -6,14 +6,24 @@ export const userCreateSchema = z.object({
|
|
|
6
6
|
image: z.string().url().optional(),
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Password rules, shared by every route that sets one.
|
|
11
|
+
*
|
|
12
|
+
* Registration and password reset must not drift: a reset that accepts a weaker
|
|
13
|
+
* password than signup would let a user downgrade their own credential, and the
|
|
14
|
+
* reverse would lock a legitimate password out of recovery. Composed into
|
|
15
|
+
* `userRegisterSchema` rather than duplicated, so there is one definition.
|
|
16
|
+
*/
|
|
17
|
+
export const passwordSchema = z
|
|
18
|
+
.string()
|
|
19
|
+
.min(8, "Password must be at least 8 characters")
|
|
20
|
+
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
21
|
+
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
22
|
+
.regex(/[0-9]/, "Password must contain at least one number");
|
|
23
|
+
|
|
9
24
|
export const userRegisterSchema = z.object({
|
|
10
25
|
email: z.string().email("Invalid email address"),
|
|
11
|
-
password:
|
|
12
|
-
.string()
|
|
13
|
-
.min(8, "Password must be at least 8 characters")
|
|
14
|
-
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
15
|
-
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
16
|
-
.regex(/[0-9]/, "Password must contain at least one number"),
|
|
26
|
+
password: passwordSchema,
|
|
17
27
|
name: z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
18
28
|
});
|
|
19
29
|
|
|
@@ -23,6 +23,19 @@ export function requireResetPasswordEmailData(data) {
|
|
|
23
23
|
return { userId, resetUrl };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function requireVerifyEmailData(data) {
|
|
27
|
+
const userId = data?.userId;
|
|
28
|
+
const verifyUrl = data?.verifyUrl;
|
|
29
|
+
|
|
30
|
+
if (!userId || !verifyUrl) {
|
|
31
|
+
throw new ValidationError(
|
|
32
|
+
"userId and verifyUrl are required for SEND_VERIFY_EMAIL job",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { userId, verifyUrl };
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
export function requireUserEmailRecord(userId, userRecord) {
|
|
27
40
|
if (!userRecord?.email) {
|
|
28
41
|
throw new AppError(
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { createEmailService } from "@usequark/quark-core/email";
|
|
7
7
|
import {
|
|
8
|
+
emailVerificationEmail,
|
|
8
9
|
passwordResetEmail,
|
|
9
10
|
welcomeEmail,
|
|
10
11
|
} from "@usequark/quark-core/email-templates";
|
|
@@ -13,6 +14,7 @@ import { JOB_NAMES } from "@usequark/quark-jobs";
|
|
|
13
14
|
import {
|
|
14
15
|
requireResetPasswordEmailData,
|
|
15
16
|
requireUserEmailRecord,
|
|
17
|
+
requireVerifyEmailData,
|
|
16
18
|
requireWelcomeEmailUserId,
|
|
17
19
|
} from "./email-job-validation.js";
|
|
18
20
|
|
|
@@ -94,5 +96,42 @@ export async function handleSendResetPasswordEmail(bullJob, logger) {
|
|
|
94
96
|
return { success: true, userId, email: userRecord.email };
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Job handler for SEND_VERIFY_EMAIL
|
|
101
|
+
* @param {import("bullmq").Job} bullJob
|
|
102
|
+
* @param {import("@usequark/quark-core").Logger} logger
|
|
103
|
+
*/
|
|
104
|
+
export async function handleSendVerificationEmail(bullJob, logger) {
|
|
105
|
+
const { userId, verifyUrl } = requireVerifyEmailData(bullJob.data);
|
|
106
|
+
|
|
107
|
+
logger.info(`Sending email verification for user ${userId}`, {
|
|
108
|
+
job: JOB_NAMES.SEND_VERIFY_EMAIL,
|
|
109
|
+
userId,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const userRecord = requireUserEmailRecord(
|
|
113
|
+
userId,
|
|
114
|
+
await prisma.user.findUnique({
|
|
115
|
+
where: { id: userId },
|
|
116
|
+
select: { email: true, name: true },
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const template = emailVerificationEmail({
|
|
121
|
+
name: userRecord.name,
|
|
122
|
+
appName: process.env.APP_NAME,
|
|
123
|
+
verifyUrl,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await emailService.sendEmail(
|
|
127
|
+
userRecord.email,
|
|
128
|
+
template.subject,
|
|
129
|
+
template.html,
|
|
130
|
+
template.text,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return { success: true, userId, email: userRecord.email };
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
// Governance alerts are sent inline by the governance-check handler.
|
|
98
137
|
// No standalone handler needed in Quark.
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
import { JOB_NAMES } from "@usequark/quark-jobs";
|
|
8
8
|
import {
|
|
9
9
|
handleSendResetPasswordEmail,
|
|
10
|
+
handleSendVerificationEmail,
|
|
10
11
|
handleSendWelcomeEmail,
|
|
11
12
|
} from "./email.js";
|
|
12
13
|
import { handleCleanupOrphanedFiles } from "./files.js";
|
|
13
14
|
|
|
14
15
|
export const jobHandlers = {
|
|
15
16
|
[JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
|
|
17
|
+
[JOB_NAMES.SEND_VERIFY_EMAIL]: handleSendVerificationEmail,
|
|
16
18
|
[JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: handleSendResetPasswordEmail,
|
|
17
19
|
[JOB_NAMES.CLEANUP_ORPHANED_FILES]: handleCleanupOrphanedFiles,
|
|
18
20
|
};
|