@usequark/quark-create-app 1.25.15 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/package.json +2 -3
  2. package/src/index.js +10 -3
  3. package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
  4. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
  5. package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +104 -0
  6. package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
  7. package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
  8. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
  9. package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
  10. package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
  11. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
  12. package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
  13. package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
  14. package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
  15. package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
  16. package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
  17. package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
  18. package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
  19. package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
  20. package/templates/base-project/packages/db/src/queries.js +13 -0
  21. package/templates/base-project/packages/db/src/schemas.js +16 -6
  22. package/templates/jobs/src/definitions.js +1 -0
  23. package/templates/worker/src/handlers/email-job-validation.js +13 -0
  24. package/templates/worker/src/handlers/email.js +39 -0
  25. package/templates/worker/src/handlers/index.js +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.15",
3
+ "version": "1.26.0",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -59,9 +59,8 @@
59
59
  "sync-templates:check": "node scripts/sync-templates.js --check",
60
60
  "regen-migration": "node scripts/regenerate-template-migration.js",
61
61
  "smoke:published": "node scripts/smoke-published.js",
62
- "test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-warning.test.js && node --test src/scaffold-output.test.js && node --test src/scaffold-drift.test.js && node --test src/mobile-template.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js'",
62
+ "test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-warning.test.js && node --test src/scaffold-output.test.js && node --test src/scaffold-drift.test.js && node --test src/mobile-template.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js' && node --test test-flags.js",
63
63
  "test:build": "node test-build.js",
64
- "test:e2e": "node test-e2e.js",
65
64
  "test:e2e:full": "node test-e2e-full.js",
66
65
  "test:integration": "node test-integration.js",
67
66
  "test:flags": "node --test test-flags.js",
package/src/index.js CHANGED
@@ -705,8 +705,11 @@ program
705
705
  ` as its own repository, or\n` +
706
706
  ` • scaffold into a temporary folder and move the files to\n` +
707
707
  ` the repository root before the first commit.\n\n` +
708
- ` Git initialization will be skipped to avoid creating a\n` +
709
- ` nested repository.\n\n` +
708
+ ` Git initialization ${
709
+ process.env.QUARK_FORCE_GIT_INIT === "true"
710
+ ? "will run anyway (QUARK_FORCE_GIT_INIT=true)."
711
+ : "will be skipped to avoid creating a\n nested repository."
712
+ }\n\n` +
710
713
  ` GitHub only runs workflows stored at the repository root, so\n` +
711
714
  ` <project>/.github/workflows/*.yml will be ignored by GitHub\n` +
712
715
  ` Actions.\n`,
@@ -1577,7 +1580,11 @@ STORAGE_PROVIDER=local
1577
1580
  // Step 11: Initialize git repository
1578
1581
  if (process.env.QUARK_SKIP_GIT_INIT === "true") {
1579
1582
  console.log(chalk.dim("\n · Skipping git initialization"));
1580
- } else if (enclosingRepo) {
1583
+ } else if (enclosingRepo && process.env.QUARK_FORCE_GIT_INIT !== "true") {
1584
+ // `initializeGit()` also checks QUARK_FORCE_GIT_INIT internally,
1585
+ // but it is only reached in the else branch below — short-
1586
+ // circuiting here on `enclosingRepo` alone made the documented
1587
+ // force flag a no-op.
1581
1588
  console.log(
1582
1589
  chalk.dim(
1583
1590
  "\n · Skipping git initialization (inside an existing repository)",
@@ -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 { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
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
+ }
@@ -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
  }