@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.
Files changed (40) hide show
  1. package/README.md +6 -8
  2. package/package.json +1 -1
  3. package/src/index.js +75 -30
  4. package/templates/base-project/apps/web/.env.example +10 -0
  5. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +24 -0
  6. package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
  7. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +5 -1
  8. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +57 -8
  9. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
  10. package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +105 -0
  11. package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
  12. package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
  13. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
  14. package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
  15. package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
  16. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
  17. package/templates/base-project/apps/web/src/lib/auth-email-verification.js +5 -0
  18. package/templates/base-project/apps/web/src/lib/auth.js +31 -10
  19. package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
  20. package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
  21. package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
  22. package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
  23. package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
  24. package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
  25. package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
  26. package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
  27. package/templates/base-project/apps/web/src/proxy.js +48 -7
  28. package/templates/base-project/packages/db/prisma/schema.prisma +7 -2
  29. package/templates/base-project/packages/db/src/queries.js +13 -0
  30. package/templates/base-project/packages/db/src/schemas.js +16 -6
  31. package/templates/config/src/environment.js +5 -0
  32. package/templates/config/src/load-config.js +8 -0
  33. package/templates/config/src/validate-env.js +5 -0
  34. package/templates/jobs/src/definitions.js +1 -0
  35. package/templates/worker/package.json +1 -0
  36. package/templates/worker/scripts/sync-readme.js +41 -0
  37. package/templates/worker/src/handlers/email-job-validation.js +13 -0
  38. package/templates/worker/src/handlers/email.js +39 -0
  39. package/templates/worker/src/handlers/index.js +2 -0
  40. package/templates/worker/src/readme-table.js +68 -0
@@ -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 { QuarkLogo } from "@usequark/quark-ui";
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
  }
@@ -0,0 +1,280 @@
1
+ "use client";
2
+
3
+ import { clearCsrfToken, getCsrfToken } from "@usequark/quark-core/csrf-client";
4
+ import {
5
+ Button,
6
+ ErrorBanner,
7
+ Label,
8
+ PasswordInput,
9
+ QuarkLogo,
10
+ } from "@usequark/quark-ui";
11
+ import Link from "next/link";
12
+ import { useSearchParams } from "next/navigation";
13
+ import { Suspense, useEffect, useState } from "react";
14
+
15
+ const STRENGTH_LABELS = ["", "Weak", "Fair", "Good", "Strong"];
16
+ const STRENGTH_COLORS = [
17
+ "",
18
+ "bg-danger",
19
+ "bg-warning",
20
+ "bg-primary",
21
+ "bg-success",
22
+ ];
23
+
24
+ function getPasswordStrength(value) {
25
+ if (!value) return 0;
26
+ let score = 0;
27
+ if (value.length >= 8) score++;
28
+ if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score++;
29
+ if (/\d/.test(value)) score++;
30
+ if (/[^a-zA-Z0-9]/.test(value)) score++;
31
+ return score;
32
+ }
33
+
34
+ function validatePassword(value) {
35
+ if (getPasswordStrength(value) < 3) {
36
+ return "Must be 8+ characters with uppercase, lowercase, and a number.";
37
+ }
38
+ return "";
39
+ }
40
+
41
+ export default function ResetPasswordPageClient() {
42
+ return (
43
+ <Suspense>
44
+ <ResetPasswordForm />
45
+ </Suspense>
46
+ );
47
+ }
48
+
49
+ function ResetPasswordForm() {
50
+ const searchParams = useSearchParams();
51
+ const token = searchParams.get("token") || "";
52
+
53
+ // `invalid` means the link is unusable (missing, expired, consumed).
54
+ // `error` means the validity check itself failed and the form stays down
55
+ // rather than letting a user type a new password into a form that might not
56
+ // work.
57
+ const [linkState, setLinkState] = useState("checking");
58
+ const [password, setPassword] = useState("");
59
+ const [confirmPassword, setConfirmPassword] = useState("");
60
+ const [passwordError, setPasswordError] = useState("");
61
+ const [error, setError] = useState("");
62
+ const [loading, setLoading] = useState(false);
63
+ const [done, setDone] = useState(false);
64
+ const strength = getPasswordStrength(password);
65
+
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+
69
+ void (async () => {
70
+ try {
71
+ const res = await fetch(
72
+ `/api/auth/reset-password?token=${encodeURIComponent(token)}`,
73
+ );
74
+ if (!cancelled) {
75
+ setLinkState(res.ok ? "ready" : "invalid");
76
+ }
77
+ } catch {
78
+ if (!cancelled) setLinkState("error");
79
+ }
80
+ })();
81
+
82
+ return () => {
83
+ cancelled = true;
84
+ };
85
+ }, [token]);
86
+
87
+ async function handleSubmit(e) {
88
+ e.preventDefault();
89
+ setError("");
90
+
91
+ const pwdErr = validatePassword(password);
92
+ if (pwdErr) {
93
+ setPasswordError(pwdErr);
94
+ return;
95
+ }
96
+
97
+ if (password !== confirmPassword) {
98
+ setPasswordError("Passwords do not match.");
99
+ return;
100
+ }
101
+
102
+ setPasswordError("");
103
+ setLoading(true);
104
+
105
+ try {
106
+ let res = await postReset(await getCsrfToken());
107
+
108
+ if (res.status === 401) {
109
+ clearCsrfToken();
110
+ res = await postReset(await getCsrfToken());
111
+ }
112
+
113
+ if (!res.ok) {
114
+ const body = await res.json().catch(() => ({}));
115
+ setError(body.message || "Reset failed. Try again.");
116
+ setLoading(false);
117
+ return;
118
+ }
119
+
120
+ setDone(true);
121
+ setLoading(false);
122
+ } catch {
123
+ setError("Could not reach the server. Try again.");
124
+ setLoading(false);
125
+ }
126
+ }
127
+
128
+ async function postReset(csrfToken) {
129
+ return await fetch("/api/auth/reset-password", {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Type": "application/json",
133
+ "X-CSRF-Token": csrfToken,
134
+ },
135
+ body: JSON.stringify({ token, password }),
136
+ });
137
+ }
138
+
139
+ return (
140
+ <div className="quark-auth-layout">
141
+ {/* Authority / branding panel */}
142
+ <div className="quark-auth-brand">
143
+ <Link href="/" aria-label="Go to home">
144
+ <QuarkLogo size={64} className="mb-8" />
145
+ </Link>
146
+ <p className="text-xs uppercase tracking-widest text-text-faint mb-3">
147
+ Account recovery
148
+ </p>
149
+ <h2 className="text-3xl font-bold text-text leading-tight mb-4">
150
+ Choose a new password.
151
+ </h2>
152
+ <p className="text-sm text-text-muted leading-relaxed">
153
+ Once set, every existing session is signed out — including any session
154
+ the old password was signed in from.
155
+ </p>
156
+ </div>
157
+
158
+ {/* Form panel */}
159
+ <div className="quark-auth-form">
160
+ {/* Mobile-only logo */}
161
+ <div className="flex md:hidden mb-8">
162
+ <Link href="/" aria-label="Go to home">
163
+ <QuarkLogo size={44} />
164
+ </Link>
165
+ </div>
166
+
167
+ <div className="quark-auth-panel w-full max-w-sm">
168
+ <div className="mb-8">
169
+ <h1 className="text-2xl font-bold tracking-tight text-text">
170
+ Reset password
171
+ </h1>
172
+ </div>
173
+
174
+ <ErrorBanner message={error} />
175
+
176
+ {linkState === "checking" ? (
177
+ <p className="text-sm text-text-muted leading-relaxed mb-8">
178
+ Checking your link…
179
+ </p>
180
+ ) : null}
181
+
182
+ {linkState === "invalid" || linkState === "error" ? (
183
+ <>
184
+ <p className="text-sm text-text-muted leading-relaxed mb-8">
185
+ This reset link is invalid or has expired. Request a new one
186
+ from the forgot-password page.
187
+ </p>
188
+ <Link
189
+ href="/auth/forgot-password"
190
+ className="text-xs text-primary hover:opacity-75 transition-opacity duration-200 linear uppercase tracking-widest"
191
+ >
192
+ Request a new link
193
+ </Link>
194
+ </>
195
+ ) : null}
196
+
197
+ {linkState === "ready" && !done ? (
198
+ <form onSubmit={handleSubmit} className="space-y-5">
199
+ <div className="space-y-2">
200
+ <Label htmlFor="password">New password</Label>
201
+ <PasswordInput
202
+ id="password"
203
+ autoComplete="new-password"
204
+ required
205
+ value={password}
206
+ onChange={(e) => {
207
+ setPassword(e.target.value);
208
+ if (passwordError)
209
+ setPasswordError(validatePassword(e.target.value));
210
+ }}
211
+ />
212
+ {password && (
213
+ <div className="space-y-1">
214
+ <div className="flex gap-0.5">
215
+ {[1, 2, 3, 4].map((seg) => (
216
+ <div
217
+ key={seg}
218
+ className={`h-0.5 flex-1 transition-colors duration-200 linear ${strength >= seg ? STRENGTH_COLORS[strength] : "bg-border"}`}
219
+ />
220
+ ))}
221
+ </div>
222
+ <p className="text-xs text-text-faint uppercase tracking-widest">
223
+ {STRENGTH_LABELS[strength]}
224
+ </p>
225
+ </div>
226
+ )}
227
+ </div>
228
+
229
+ <div className="space-y-2">
230
+ <Label htmlFor="confirm-password">Confirm new password</Label>
231
+ <PasswordInput
232
+ id="confirm-password"
233
+ autoComplete="new-password"
234
+ required
235
+ value={confirmPassword}
236
+ onChange={(e) => {
237
+ setConfirmPassword(e.target.value);
238
+ if (passwordError) setPasswordError("");
239
+ }}
240
+ />
241
+ {passwordError ? (
242
+ <p className="text-xs text-danger uppercase tracking-widest">
243
+ {passwordError}
244
+ </p>
245
+ ) : null}
246
+ </div>
247
+
248
+ <Button
249
+ type="submit"
250
+ variant="primary"
251
+ className="w-full mt-2"
252
+ disabled={loading}
253
+ >
254
+ {loading ? "Resetting…" : "Set new password →"}
255
+ </Button>
256
+ </form>
257
+ ) : null}
258
+
259
+ {done ? (
260
+ <>
261
+ <p
262
+ role="status"
263
+ className="text-sm text-text-muted leading-relaxed mb-8"
264
+ >
265
+ Your password has been updated. All previous sessions have been
266
+ signed out.
267
+ </p>
268
+ <Link
269
+ href="/auth/signin"
270
+ className="text-xs text-primary hover:opacity-75 transition-opacity duration-200 linear uppercase tracking-widest"
271
+ >
272
+ Sign in with your new password →
273
+ </Link>
274
+ </>
275
+ ) : null}
276
+ </div>
277
+ </div>
278
+ </div>
279
+ );
280
+ }
@@ -0,0 +1,9 @@
1
+ import ResetPasswordPageClient from "./ResetPasswordPageClient";
2
+
3
+ export const metadata = {
4
+ title: "Reset Password",
5
+ };
6
+
7
+ export default function ResetPasswordPage() {
8
+ return <ResetPasswordPageClient />;
9
+ }
@@ -37,6 +37,7 @@ function SignInForm({ allowSignup }) {
37
37
  const searchParams = useSearchParams();
38
38
  const callbackUrl = searchParams.get("callbackUrl") || "/";
39
39
  const errorCode = searchParams.get("error");
40
+ const verified = searchParams.get("verified");
40
41
 
41
42
  const [email, setEmail] = useState("");
42
43
  const [password, setPassword] = useState("");
@@ -46,6 +47,18 @@ function SignInForm({ allowSignup }) {
46
47
  const [error, setError] = useState(
47
48
  errorCode ? (ERROR_MESSAGES[errorCode] ?? ERROR_MESSAGES.Default) : "",
48
49
  );
50
+ // `/api/auth/verify-email` redirects here with ?verified=1|invalid|error.
51
+ // A notice is the difference between "why can I sign in now?" and "did my
52
+ // email even work?" for a user arriving from their mailbox.
53
+ const [notice] = useState(
54
+ verified === "1"
55
+ ? "Your email address is verified. You can sign in."
56
+ : verified === "invalid"
57
+ ? "That verification link is invalid or has expired."
58
+ : verified === "error"
59
+ ? "Verification failed. Try again from the link in your email."
60
+ : "",
61
+ );
49
62
 
50
63
  useEffect(() => {
51
64
  let cancelled = false;
@@ -131,6 +144,15 @@ function SignInForm({ allowSignup }) {
131
144
 
132
145
  <ErrorBanner message={error} />
133
146
 
147
+ {notice ? (
148
+ <div
149
+ role="status"
150
+ className="border border-success/40 bg-success-muted px-4 py-3 text-sm text-success mb-8"
151
+ >
152
+ {notice}
153
+ </div>
154
+ ) : null}
155
+
134
156
  <form onSubmit={handleSubmit} className="space-y-5">
135
157
  <div className="space-y-2">
136
158
  <Label htmlFor="email">Email</Label>
@@ -0,0 +1,5 @@
1
+ import { loadConfig } from "@usequark/quark-config";
2
+
3
+ export function isEmailVerificationRequired() {
4
+ return loadConfig().auth.requireEmailVerification;
5
+ }
@@ -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
- const existingUser = await userQueries.findByEmail(credentials.email);
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 (!isEmailVerified(profile.email_verified)) {
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,