@usequark/quark-create-app 1.25.15 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -3
- package/src/index.js +10 -3
- package/templates/base-project/apps/web/src/app/api/auth/forgot-password/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +20 -1
- package/templates/base-project/apps/web/src/app/api/auth/reset-password/route.js +104 -0
- package/templates/base-project/apps/web/src/app/api/auth/verify-email/route.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/ForgotPasswordPageClient.js +159 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +2 -51
- package/templates/base-project/apps/web/src/app/auth/reset-password/ResetPasswordPageClient.js +280 -0
- package/templates/base-project/apps/web/src/app/auth/reset-password/page.js +9 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +22 -0
- package/templates/base-project/apps/web/src/lib/enqueue-email-job.js +67 -0
- package/templates/base-project/apps/web/src/lib/enqueue-reset-password-email.js +33 -0
- package/templates/base-project/apps/web/src/lib/enqueue-verification-email.js +25 -0
- package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +13 -48
- package/templates/base-project/apps/web/src/lib/issue-verification-token.js +52 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +7 -0
- package/templates/base-project/apps/web/src/lib/send-reset-password-email.js +24 -0
- package/templates/base-project/apps/web/src/lib/send-verification-email.js +28 -0
- package/templates/base-project/packages/db/src/queries.js +13 -0
- package/templates/base-project/packages/db/src/schemas.js +16 -6
- package/templates/jobs/src/definitions.js +1 -0
- package/templates/worker/src/handlers/email-job-validation.js +13 -0
- package/templates/worker/src/handlers/email.js +39 -0
- package/templates/worker/src/handlers/index.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-create-app",
|
|
3
|
-
"version": "1.
|
|
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
|
|
709
|
-
|
|
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 {
|
|
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
|
}
|