allow password creations

This commit is contained in:
liyunze 2026-08-31 18:13:34 +08:00
parent 9db0df3982
commit 4faf0929b1
7 changed files with 483 additions and 120 deletions

View file

@ -5,4 +5,4 @@ DATABASE_PATH=data/dsec.db
RESEND_API_KEY="" RESEND_API_KEY=""
# Optional "from" address for verification emails. # Optional "from" address for verification emails.
RESEND_FROM=DSEC Notebook <onboarding@resend.dev> RESEND_FROM="DSEC Notebook <onboarding@resend.dev>"

View file

@ -11,7 +11,7 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
- 💬 **Comments** — discuss notes directly. - 💬 **Comments** — discuss notes directly.
- 👍 **Voting** — upvote or downvote notes and questions. - 👍 **Voting** — upvote or downvote notes and questions.
- 🔍 **Search** — find notes by title or content. - 🔍 **Search** — find notes by title or content.
- 🎓 **Deakin-only sign in** — only `@deakin.edu.au` email addresses can contribute. - 🎓 **Deakin-only accounts** — only `@deakin.edu.au` email addresses can contribute. Create an account with a password after verifying your email, then sign in with email and password.
- 🗂️ **Units & topics** — browse content by Deakin unit code (e.g. `SIT102`, `SIT192`) or topic (e.g. Algorithms, Mathematics). - 🗂️ **Units & topics** — browse content by Deakin unit code (e.g. `SIT102`, `SIT192`) or topic (e.g. Algorithms, Mathematics).
- 💾 **Persistent storage** — all data is stored in a local SQLite database. - 💾 **Persistent storage** — all data is stored in a local SQLite database.
@ -118,7 +118,7 @@ src/
- The frontend calls a single JSON API endpoint (`POST /api`) with a function name and arguments. - The frontend calls a single JSON API endpoint (`POST /api`) with a function name and arguments.
- The server dispatches those calls to handlers in `src/lib/server/api.ts`, backed by SQLite. - The server dispatches those calls to handlers in `src/lib/server/api.ts`, backed by SQLite.
- On first run, the database is created automatically and seeded with common Deakin SIT/Math units and CS/maths topics. - On first run, the database is created automatically and seeded with common Deakin SIT/Math units and CS/maths topics.
- Authentication is email-verified: signing in with a `@deakin.edu.au` address sends a 6-digit code via Resend, which the user must enter to prove they own the inbox. Verified users are created or reused and given a session token stored in `localStorage`. - Authentication is email-verified: to create an account you sign in with a `@deakin.edu.au` address, a 6-digit code is sent via Resend, and after entering the code you set a password. Once created, you sign in with just your email and password. A "forgot password" flow sends a reset code to your Deakin address so you can set a new password. Passwords are stored as salted scrypt hashes, and sessions are stored as tokens in `localStorage`.
## Data model ## Data model

View file

@ -1,6 +1,13 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { createHash, randomInt, randomUUID } from "node:crypto"; import {
import { sendVerificationCode } from "./email"; createHash,
randomBytes,
randomInt,
randomUUID,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { sendPasswordResetCode, sendVerificationCode } from "./email";
const DEAKIN_DOMAIN = "deakin.edu.au"; const DEAKIN_DOMAIN = "deakin.edu.au";
const CODE_TTL_MS = 10 * 60 * 1000; const CODE_TTL_MS = 10 * 60 * 1000;
@ -30,6 +37,28 @@ function hashCode(code: string): string {
return createHash("sha256").update(code).digest("hex"); return createHash("sha256").update(code).digest("hex");
} }
function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split(":");
if (!salt || !hash) return false;
const candidate = scryptSync(password, salt, 64);
const expected = Buffer.from(hash, "hex");
if (candidate.length !== expected.length) return false;
return timingSafeEqual(candidate, expected);
}
function validatePassword(password: string): string {
if (!password || password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
return password;
}
function normalizeEmail(email: string): string { function normalizeEmail(email: string): string {
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
const emailDomain = normalized.split("@")[1]?.toLowerCase(); const emailDomain = normalized.split("@")[1]?.toLowerCase();
@ -55,38 +84,13 @@ function mapQuestion(row: Record<string, any>): Record<string, any> {
// ---- users ---- // ---- users ----
function createOrReuseUser(db: Db, email: string, name: string) { function issueSession(db: Db, user: { _id: string; name: string }) {
const existing = db
.prepare(
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?",
)
.get(email) as Record<string, any> | undefined;
const token = generateToken(); const token = generateToken();
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, user._id);
if (existing) { return { userId: user._id, token, name: user.name };
db.prepare("UPDATE users SET sessionToken = ?, name = ? WHERE id = ?").run(
token,
name,
existing._id,
);
return { userId: existing._id, token, name };
}
const id = newId();
db.prepare(
"INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)",
).run(id, email, name, token, Date.now());
return { userId: id, token, name };
} }
async function authRequestCode(db: Db, args: { email: string; name: string }) { async function sendCode(db: Db, email: string, name: string, kind: "verification" | "reset") {
const email = normalizeEmail(args.email);
if (!args.name || !args.name.trim()) {
throw new Error("Name is required");
}
const name = args.name.trim();
const existing = db const existing = db
.prepare("SELECT email, createdAt FROM email_verifications WHERE email = ?") .prepare("SELECT email, createdAt FROM email_verifications WHERE email = ?")
.get(email) as { email: string; createdAt: number } | undefined; .get(email) as { email: string; createdAt: number } | undefined;
@ -111,7 +115,11 @@ async function authRequestCode(db: Db, args: { email: string; name: string }) {
} }
try { try {
if (kind === "reset") {
await sendPasswordResetCode(email, code);
} else {
await sendVerificationCode(email, code); await sendVerificationCode(email, code);
}
} catch (err) { } catch (err) {
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email); db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
throw err; throw err;
@ -119,9 +127,7 @@ async function authRequestCode(db: Db, args: { email: string; name: string }) {
return { ok: true }; return { ok: true };
} }
function authVerifyCode(db: Db, args: { email: string; code: string }) { function verifyAndConsumeCode(db: Db, email: string, code: string): { name: string } {
const email = normalizeEmail(args.email);
const pending = db const pending = db
.prepare( .prepare(
"SELECT name, codeHash, expiresAt, attempts FROM email_verifications WHERE email = ?", "SELECT name, codeHash, expiresAt, attempts FROM email_verifications WHERE email = ?",
@ -134,7 +140,7 @@ function authVerifyCode(db: Db, args: { email: string; code: string }) {
if (Date.now() > pending.expiresAt) throw new Error("Verification code has expired"); if (Date.now() > pending.expiresAt) throw new Error("Verification code has expired");
if (pending.attempts >= MAX_ATTEMPTS) throw new Error("Too many attempts. Request a new code."); if (pending.attempts >= MAX_ATTEMPTS) throw new Error("Too many attempts. Request a new code.");
if (hashCode(args.code) !== pending.codeHash) { if (hashCode(code) !== pending.codeHash) {
db.prepare("UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?").run( db.prepare("UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?").run(
email, email,
); );
@ -142,7 +148,71 @@ function authVerifyCode(db: Db, args: { email: string; code: string }) {
} }
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email); db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
return createOrReuseUser(db, email, pending.name); return { name: pending.name };
}
async function authRequestCode(db: Db, args: { email: string; name: string }) {
const email = normalizeEmail(args.email);
if (!args.name || !args.name.trim()) {
throw new Error("Name is required");
}
return await sendCode(db, email, args.name.trim(), "verification");
}
function authSignup(db: Db, args: { email: string; code: string; password: string }) {
const email = normalizeEmail(args.email);
const password = validatePassword(args.password);
const pending = verifyAndConsumeCode(db, email, args.code);
const existing = db.prepare("SELECT id AS _id FROM users WHERE email = ?").get(email) as
| { _id: string }
| undefined;
if (existing) throw new Error("An account already exists for this email. Sign in instead.");
const id = newId();
db.prepare(
"INSERT INTO users (id, email, name, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)",
).run(id, email, pending.name, hashPassword(password), Date.now());
return issueSession(db, { _id: id, name: pending.name });
}
function authSignin(db: Db, args: { email: string; password: string }) {
const email = normalizeEmail(args.email);
const user = db
.prepare("SELECT id AS _id, name, passwordHash FROM users WHERE email = ?")
.get(email) as { _id: string; name: string; passwordHash: string | null } | undefined;
if (!user || !user.passwordHash) throw new Error("No account found for this email");
if (!verifyPassword(args.password, user.passwordHash)) throw new Error("Incorrect password");
return issueSession(db, { _id: user._id, name: user.name });
}
async function authForgotPassword(db: Db, args: { email: string }) {
const email = normalizeEmail(args.email);
const user = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as
| { _id: string; name: string }
| undefined;
if (!user) throw new Error("No account found for this email");
return await sendCode(db, email, user.name, "reset");
}
function authResetPassword(db: Db, args: { email: string; code: string; password: string }) {
const email = normalizeEmail(args.email);
const password = validatePassword(args.password);
verifyAndConsumeCode(db, email, args.code);
const user = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as
| { _id: string; name: string }
| undefined;
if (!user) throw new Error("No account found for this email");
db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run(
hashPassword(password),
user._id,
);
return issueSession(db, { _id: user._id, name: user.name });
} }
function usersGetByToken(db: Db, args: { token: string }) { function usersGetByToken(db: Db, args: { token: string }) {
@ -499,7 +569,10 @@ type Handler = (db: Db, args: any) => any;
const handlers: Record<string, Handler> = { const handlers: Record<string, Handler> = {
"auth:requestCode": authRequestCode, "auth:requestCode": authRequestCode,
"auth:verifyCode": authVerifyCode, "auth:signup": authSignup,
"auth:signin": authSignin,
"auth:forgotPassword": authForgotPassword,
"auth:resetPassword": authResetPassword,
"users:getByToken": usersGetByToken, "users:getByToken": usersGetByToken,
"topics:getBySlug": topicsGetBySlug, "topics:getBySlug": topicsGetBySlug,
"topics:getAll": topicsGetAll, "topics:getAll": topicsGetAll,

View file

@ -15,6 +15,7 @@ function createSchema(database: DatabaseSync) {
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, name TEXT NOT NULL,
sessionToken TEXT, sessionToken TEXT,
passwordHash TEXT,
createdAt INTEGER NOT NULL createdAt INTEGER NOT NULL
); );
@ -204,6 +205,13 @@ const SEED_UNITS = [
{ code: "MIS798", name: "Business Process Management" }, { code: "MIS798", name: "Business Process Management" },
]; ];
function migrate(database: DatabaseSync) {
const columns = database.prepare("PRAGMA table_info(users)").all() as { name: string }[];
if (!columns.some((c) => c.name === "passwordHash")) {
database.exec("ALTER TABLE users ADD COLUMN passwordHash TEXT");
}
}
function seed(database: DatabaseSync) { function seed(database: DatabaseSync) {
const existing = database.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number }; const existing = database.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number };
if (existing.c > 0) return; if (existing.c > 0) return;
@ -233,6 +241,7 @@ export function getDb(): DatabaseSync {
mkdirSync(dirname(DB_PATH), { recursive: true }); mkdirSync(dirname(DB_PATH), { recursive: true });
db = new DatabaseSync(DB_PATH); db = new DatabaseSync(DB_PATH);
createSchema(db); createSchema(db);
migrate(db);
seed(db); seed(db);
} }
return db; return db;

View file

@ -4,24 +4,40 @@ import { RESEND_API_KEY, RESEND_FROM } from "$env/static/private";
const API_KEY = RESEND_API_KEY; const API_KEY = RESEND_API_KEY;
const FROM = RESEND_FROM; const FROM = RESEND_FROM;
export async function sendVerificationCode(email: string, code: string): Promise<void> { async function send(email: string, subject: string, text: string): Promise<void> {
if (!API_KEY) { if (!API_KEY) {
throw new Error("Server error: API error is not configured"); throw new Error("Server error: API key is not configured");
} }
if (!FROM) { if (!FROM) {
throw new Error("Server error: Email is not configured"); throw new Error("Server error: From address is not configured");
} }
const resend = new Resend(API_KEY); const resend = new Resend(API_KEY);
const { error } = await resend.emails.send({ const { error } = await resend.emails.send({
from: FROM, from: FROM,
to: email, to: email,
subject: "Your DSEC Notebook verification code", subject,
text: `Your verification code is ${code}. It expires in 10 minutes.`, text,
}); });
if (error) { if (error) {
throw new Error(error.message); throw new Error(error.message);
} }
} }
export function sendVerificationCode(email: string, code: string): Promise<void> {
return send(
email,
"Your DSEC Notebook verification code",
`Your verification code is ${code}. It expires in 10 minutes.`,
);
}
export function sendPasswordResetCode(email: string, code: string): Promise<void> {
return send(
email,
"Your DSEC Notebook password reset code",
`Your password reset code is ${code}. It expires in 10 minutes. If you did not request this, you can ignore this email.`,
);
}

View file

@ -25,12 +25,7 @@ export async function initAuth() {
} }
} }
export async function requestCode(email: string, name: string) { function setSession(result: { userId: string; token: string; name: string }, email: string) {
return await mutation("auth:requestCode", { email, name });
}
export async function verifyCode(email: string, code: string) {
const result = await mutation("auth:verifyCode", { email, code });
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token })); localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
currentUser.set({ currentUser.set({
_id: result.userId, _id: result.userId,
@ -43,6 +38,29 @@ export async function verifyCode(email: string, code: string) {
return result; return result;
} }
export async function requestCode(email: string, name: string) {
return await mutation("auth:requestCode", { email, name });
}
export async function signUp(email: string, code: string, password: string) {
const result = await mutation("auth:signup", { email, code, password });
return setSession(result, email);
}
export async function signIn(email: string, password: string) {
const result = await mutation("auth:signin", { email, password });
return setSession(result, email);
}
export async function forgotPassword(email: string) {
return await mutation("auth:forgotPassword", { email });
}
export async function resetPassword(email: string, code: string, password: string) {
const result = await mutation("auth:resetPassword", { email, code, password });
return setSession(result, email);
}
export function getToken(): string | null { export function getToken(): string | null {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null; if (!stored) return null;

View file

@ -1,33 +1,72 @@
<script lang="ts"> <script lang="ts">
import { requestCode, verifyCode } from "$lib/stores/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { forgotPassword, requestCode, resetPassword, signIn, signUp } from "$lib/stores/auth";
let step = $state<"email" | "code">("email"); type Mode = "signin" | "signup" | "forgot";
let mode = $state<Mode>("signin");
let email = $state(""); let email = $state("");
let name = $state(""); let name = $state("");
let code = $state(""); let code = $state("");
let password = $state("");
let sent = $state(false);
let error = $state(""); let error = $state("");
let loading = $state(false); let loading = $state(false);
const isDeakin = (value: string) => value.toLowerCase().endsWith("@deakin.edu.au");
function switchMode(next: Mode) {
mode = next;
email = "";
name = "";
code = "";
password = "";
sent = false;
error = "";
}
function goBack() {
sent = false;
code = "";
password = "";
error = "";
}
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
error = ""; error = "";
if (step === "email") { if (mode === "signin") {
if (!email.trim() || !name.trim()) { if (!email.trim() || !password) {
error = "Please enter your email and password";
return;
}
loading = true;
try {
await signIn(email.trim(), password);
goto("/");
} catch (err: any) {
error = err.message ?? "Sign in failed. Please try again.";
} finally {
loading = false;
}
return;
}
if (mode === "signup") {
if (!sent) {
if (!name.trim() || !email.trim()) {
error = "Please fill in all fields"; error = "Please fill in all fields";
return; return;
} }
if (!isDeakin(email)) {
if (!email.toLowerCase().endsWith("@deakin.edu.au")) {
error = "Only @deakin.edu.au email addresses are allowed"; error = "Only @deakin.edu.au email addresses are allowed";
return; return;
} }
loading = true; loading = true;
try { try {
await requestCode(email.trim(), name.trim()); await requestCode(email.trim(), name.trim());
step = "code"; sent = true;
} catch (err: any) { } catch (err: any) {
error = err.message ?? "Failed to send the code. Please try again."; error = err.message ?? "Failed to send the code. Please try again.";
} finally { } finally {
@ -40,22 +79,61 @@
error = "Please enter the verification code"; error = "Please enter the verification code";
return; return;
} }
if (password.length < 8) {
error = "Password must be at least 8 characters";
return;
}
loading = true; loading = true;
try { try {
await verifyCode(email.trim(), code.trim()); await signUp(email.trim(), code.trim(), password);
goto("/"); goto("/");
} catch (err: any) { } catch (err: any) {
error = err.message ?? "Verification failed. Please try again."; error = err.message ?? "Failed to create your account. Please try again.";
} finally { } finally {
loading = false; loading = false;
} }
return;
} }
function goBack() { // forgot
step = "email"; if (!sent) {
code = ""; if (!email.trim()) {
error = ""; error = "Please enter your Deakin email";
return;
}
if (!isDeakin(email)) {
error = "Only @deakin.edu.au email addresses are allowed";
return;
}
loading = true;
try {
await forgotPassword(email.trim());
sent = true;
} catch (err: any) {
error = err.message ?? "Failed to send the code. Please try again.";
} finally {
loading = false;
}
return;
}
if (!code.trim()) {
error = "Please enter the reset code";
return;
}
if (password.length < 8) {
error = "Password must be at least 8 characters";
return;
}
loading = true;
try {
await resetPassword(email.trim(), code.trim(), password);
goto("/");
} catch (err: any) {
error = err.message ?? "Failed to reset your password. Please try again.";
} finally {
loading = false;
}
} }
</script> </script>
@ -66,10 +144,70 @@
<div class="page flex min-h-[calc(100vh-220px)] items-center"> <div class="page flex min-h-[calc(100vh-220px)] items-center">
<div class="mx-auto w-full max-w-md"> <div class="mx-auto w-full max-w-md">
<p class="kicker">Account</p> <p class="kicker">Account</p>
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">Sign in</h1> <h1 class="text-ink mt-2 font-serif text-4xl font-medium">
{mode === "signin"
? "Sign in"
: mode === "signup"
? "Create account"
: "Reset password"}
</h1>
<p class="text-muted mt-2 mb-8 text-[15px]">Use your Deakin email to contribute.</p> <p class="text-muted mt-2 mb-8 text-[15px]">Use your Deakin email to contribute.</p>
{#if step === "email"} {#if mode === "signin"}
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<div>
<label for="email" class="kicker mb-2 block">Deakin email</label>
<input
id="email"
type="email"
bind:value={email}
placeholder="@deakin.edu.au"
autocomplete="email"
class="field"
required
/>
</div>
<div>
<label for="password" class="kicker mb-2 block">Password</label>
<input
id="password"
type="password"
bind:value={password}
placeholder="Your password"
autocomplete="current-password"
class="field"
required
/>
</div>
{#if error}
<p class="text-primary text-sm">{error}</p>
{/if}
<button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Signing in..." : "Sign in"}
</button>
<div class="flex items-center justify-between text-sm">
<button
type="button"
onclick={() => switchMode("forgot")}
class="text-muted hover:text-ink"
>
Forgot password?
</button>
<button
type="button"
onclick={() => switchMode("signup")}
class="text-muted hover:text-ink"
>
Create account
</button>
</div>
</form>
{:else if mode === "signup"}
{#if !sent}
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8"> <form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<div> <div>
<label for="name" class="kicker mb-2 block">Full name</label> <label for="name" class="kicker mb-2 block">Full name</label>
@ -104,15 +242,19 @@
{loading ? "Sending code..." : "Send verification code"} {loading ? "Sending code..." : "Send verification code"}
</button> </button>
<p class="text-faint text-xs"> <button
By signing in, you agree that your contributions are public. type="button"
</p> onclick={() => switchMode("signin")}
class="text-muted hover:text-ink text-sm"
>
Already have an account? Sign in
</button>
</form> </form>
{:else} {:else}
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8"> <form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<p class="text-muted text-sm"> <p class="text-muted text-sm">
We sent a 6-digit code to <span class="text-ink">{email}</span>. It expires in We sent a 6-digit code to <span class="text-ink">{email}</span>. It expires
10 minutes. in 10 minutes.
</p> </p>
<div> <div>
@ -130,18 +272,123 @@
/> />
</div> </div>
<div>
<label for="password" class="kicker mb-2 block">Create password</label>
<input
id="password"
type="password"
bind:value={password}
placeholder="At least 8 characters"
autocomplete="new-password"
class="field"
required
/>
</div>
{#if error} {#if error}
<p class="text-primary text-sm">{error}</p> <p class="text-primary text-sm">{error}</p>
{/if} {/if}
<button type="submit" disabled={loading} class="btn-primary w-full"> <button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Verifying..." : "Verify & sign in"} {loading ? "Creating account..." : "Create account"}
</button> </button>
<button type="button" onclick={goBack} class="text-muted hover:text-ink text-sm"> <button
type="button"
onclick={goBack}
class="text-muted hover:text-ink text-sm"
>
Use a different email Use a different email
</button> </button>
</form> </form>
{/if} {/if}
{:else}
{#if !sent}
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<div>
<label for="email" class="kicker mb-2 block">Deakin email</label>
<input
id="email"
type="email"
bind:value={email}
placeholder="@deakin.edu.au"
autocomplete="email"
class="field"
required
/>
<p class="text-faint mt-2 text-xs">
We'll email a reset code to your @deakin.edu.au address.
</p>
</div>
{#if error}
<p class="text-primary text-sm">{error}</p>
{/if}
<button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Sending code..." : "Send reset code"}
</button>
<button
type="button"
onclick={() => switchMode("signin")}
class="text-muted hover:text-ink text-sm"
>
Back to sign in
</button>
</form>
{:else}
<form onsubmit={handleSubmit} class="border-rule space-y-5 border-t pt-8">
<p class="text-muted text-sm">
We sent a 6-digit code to <span class="text-ink">{email}</span>. It expires
in 10 minutes.
</p>
<div>
<label for="code" class="kicker mb-2 block">Reset code</label>
<input
id="code"
type="text"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
bind:value={code}
placeholder="000000"
class="field"
required
/>
</div>
<div>
<label for="password" class="kicker mb-2 block">New password</label>
<input
id="password"
type="password"
bind:value={password}
placeholder="At least 8 characters"
autocomplete="new-password"
class="field"
required
/>
</div>
{#if error}
<p class="text-primary text-sm">{error}</p>
{/if}
<button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Resetting..." : "Reset password"}
</button>
<button
type="button"
onclick={goBack}
class="text-muted hover:text-ink text-sm"
>
Use a different email
</button>
</form>
{/if}
{/if}
</div> </div>
</div> </div>