@usequark/quark-create-app 1.25.16 → 1.27.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/README.md +6 -8
- package/package.json +1 -1
- package/src/index.js +75 -30
- package/templates/base-project/apps/web/.env.example +10 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +24 -0
- 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/google/route.js +5 -1
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +57 -8
- 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 +105 -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/auth-email-verification.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +31 -10
- 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/apps/web/src/proxy.js +48 -7
- package/templates/base-project/packages/db/prisma/schema.prisma +7 -2
- package/templates/base-project/packages/db/src/queries.js +13 -0
- package/templates/base-project/packages/db/src/schemas.js +16 -6
- package/templates/config/src/environment.js +5 -0
- package/templates/config/src/load-config.js +8 -0
- package/templates/config/src/validate-env.js +5 -0
- package/templates/jobs/src/definitions.js +1 -0
- package/templates/worker/package.json +1 -0
- package/templates/worker/scripts/sync-readme.js +41 -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/worker/src/readme-table.js +68 -0
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>
|
|
@@ -7,6 +7,7 @@ import NextAuth from "next-auth";
|
|
|
7
7
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
8
8
|
import GithubProvider from "next-auth/providers/github";
|
|
9
9
|
import GoogleProvider from "next-auth/providers/google";
|
|
10
|
+
import { isEmailVerificationRequired } from "./auth-email-verification";
|
|
10
11
|
import { isEmailVerified } from "./email-verified";
|
|
11
12
|
|
|
12
13
|
const logger = createLogger({ name: "auth" });
|
|
@@ -65,8 +66,10 @@ const providers = [
|
|
|
65
66
|
return null;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
let existingUser;
|
|
70
|
+
|
|
68
71
|
try {
|
|
69
|
-
|
|
72
|
+
existingUser = await userQueries.findByEmail(credentials.email);
|
|
70
73
|
|
|
71
74
|
if (!existingUser?.password) {
|
|
72
75
|
return null;
|
|
@@ -80,14 +83,6 @@ const providers = [
|
|
|
80
83
|
if (!isValid) {
|
|
81
84
|
return null;
|
|
82
85
|
}
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
id: existingUser.id,
|
|
86
|
-
email: existingUser.email,
|
|
87
|
-
name: existingUser.name,
|
|
88
|
-
image: existingUser.image,
|
|
89
|
-
role: existingUser.role,
|
|
90
|
-
};
|
|
91
86
|
} catch (err) {
|
|
92
87
|
// Re-throw if the database is unreachable so Auth.js surfaces a server error
|
|
93
88
|
// rather than "Invalid credentials", which misleads both user and developer.
|
|
@@ -109,6 +104,29 @@ const providers = [
|
|
|
109
104
|
});
|
|
110
105
|
return null;
|
|
111
106
|
}
|
|
107
|
+
|
|
108
|
+
// Deliberately after the try/catch above. The verification refusal is a
|
|
109
|
+
// decision, not a failure: leaving it outside means the fail-closed
|
|
110
|
+
// handler cannot turn it into a bare `null` that Auth.js would report as
|
|
111
|
+
// "Invalid credentials". It throws the same `OAUTH_EMAIL_NOT_VERIFIED`
|
|
112
|
+
// error the OAuth path does, so the operator sees one reason, with one
|
|
113
|
+
// code, whichever provider the sign-in came through — and, as there, a
|
|
114
|
+
// missing or unset `emailVerified` reads as unverified.
|
|
115
|
+
if (isEmailVerificationRequired() && !existingUser.emailVerified) {
|
|
116
|
+
throw new AppError(
|
|
117
|
+
"Credentials sign-in refused: the email address is not verified",
|
|
118
|
+
401,
|
|
119
|
+
"OAUTH_EMAIL_NOT_VERIFIED",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: existingUser.id,
|
|
125
|
+
email: existingUser.email,
|
|
126
|
+
name: existingUser.name,
|
|
127
|
+
image: existingUser.image,
|
|
128
|
+
role: existingUser.role,
|
|
129
|
+
};
|
|
112
130
|
},
|
|
113
131
|
}),
|
|
114
132
|
];
|
|
@@ -142,7 +160,10 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
|
142
160
|
// why in the one place an operator can read it, and lands in the same
|
|
143
161
|
// catch: no session, no user row, redirect back to the sign-in page.
|
|
144
162
|
profile: (profile) => {
|
|
145
|
-
if (
|
|
163
|
+
if (
|
|
164
|
+
isEmailVerificationRequired() &&
|
|
165
|
+
!isEmailVerified(profile.email_verified)
|
|
166
|
+
) {
|
|
146
167
|
throw new AppError(
|
|
147
168
|
"Google sign-in refused: the account's email address is not verified",
|
|
148
169
|
401,
|