Merge pull request #1 from dsec-hub/add-auth

Added auth
This commit is contained in:
RythonDev 2026-08-31 20:25:24 +10:00 committed by GitHub
commit 456f4fb0e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 697 additions and 76 deletions

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# Path to the SQLite database file (relative to the project root).
DATABASE_PATH=data/dsec.db
# Resend API key for sending verification emails (https://resend.com/api-keys).
RESEND_API_KEY=""
# Optional "from" address for verification emails.
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.
- 👍 **Voting** — upvote or downvote notes and questions.
- 🔍 **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).
- 💾 **Persistent storage** — all data is stored in a local SQLite database.
@ -37,13 +37,15 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
npm install
```
2. Configure environment variables (optional):
2. Configure environment variables:
```sh
cp .env .env.local
```
The only variable is `DATABASE_PATH`, which defaults to `data/dsec.db`.
`DATABASE_PATH` defaults to `data/dsec.db`. To send verification emails, set
`RESEND_API_KEY` (get one at <https://resend.com/api-keys>). Without it, email
verification will fail.
3. Start the development server:
@ -56,8 +58,10 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
## Environment variables
| Variable | Description | Default |
| --------------- | --------------------------------------- | -------------- |
| ---------------- | --------------------------------------- | --------------------------------------- |
| `DATABASE_PATH` | Path to the SQLite database file | `data/dsec.db` |
| `RESEND_API_KEY` | Resend API key for verification emails | _(required)_ |
| `RESEND_FROM` | "From" address for verification emails | `DSEC Notebook <onboarding@resend.dev>` |
| `HOST` | Host the Node server binds to (build) | `0.0.0.0` |
| `PORT` | Port the Node server listens on (build) | `3000` |
@ -114,7 +118,7 @@ src/
- 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.
- On first run, the database is created automatically and seeded with common Deakin SIT/Math units and CS/maths topics.
- Authentication is session-token based: signing in with a valid `@deakin.edu.au` email creates or reuses a user and stores a token 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
@ -127,6 +131,7 @@ The SQLite database contains the following tables:
- `questions` — student questions
- `comments` — note comments and question answers
- `votes` — upvotes/downvotes on notes and questions
- `email_verifications` — pending email verification codes
## Disclaimer

View file

@ -5,6 +5,8 @@ services:
- "4073:3000"
environment:
DATABASE_PATH: data/dsec.db
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM: ${RESEND_FROM:-DSEC Notebook <onboarding@resend.dev>}
volumes:
- dsec-data:/app/data

52
package-lock.json generated
View file

@ -7,6 +7,9 @@
"": {
"name": "dsec-notebook",
"version": "0.0.1",
"dependencies": {
"resend": "^6.25.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
@ -1242,6 +1245,12 @@
"win32"
]
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -2065,6 +2074,12 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2640,6 +2655,12 @@
"node": ">=14.19.0"
}
},
"node_modules/postal-mime": {
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz",
"integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@ -2683,6 +2704,27 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resend": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.25.0.tgz",
"integrity": "sha512-iptUEycs+6Hu+W8mExK708LrDiMYOuGgnCP+wTD5L4Zrqqd2B86h1oyAmNe0khZWQw0ccI7jz8OgAaplEsyaIA==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.5",
"standardwebhooks": "1.0.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -2843,6 +2885,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",

View file

@ -15,6 +15,9 @@
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
},
"dependencies": {
"resend": "^6.25.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",

View file

@ -1,7 +1,18 @@
import { getDb } from "./db";
import { randomUUID } from "node:crypto";
import {
createHash,
randomBytes,
randomInt,
randomUUID,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { sendPasswordResetCode, sendVerificationCode } from "./email";
const DEAKIN_DOMAIN = "deakin.edu.au";
const CODE_TTL_MS = 10 * 60 * 1000;
const REQUEST_COOLDOWN_MS = 60 * 1000;
const MAX_ATTEMPTS = 5;
type Db = ReturnType<typeof getDb>;
@ -18,6 +29,45 @@ function generateToken(): string {
return result;
}
function generateCode(): string {
return String(randomInt(0, 1000000)).padStart(6, "0");
}
function hashCode(code: string): string {
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 {
const normalized = email.trim().toLowerCase();
const emailDomain = normalized.split("@")[1]?.toLowerCase();
if (emailDomain !== DEAKIN_DOMAIN) {
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
}
return normalized;
}
function requireAuth(db: Db, token: string): Record<string, any> {
const user = db
.prepare(
@ -34,31 +84,135 @@ function mapQuestion(row: Record<string, any>): Record<string, any> {
// ---- users ----
function usersRegister(db: Db, args: { email: string; name: string }) {
const email = args.email.toLowerCase();
const emailDomain = email.split("@")[1]?.toLowerCase();
if (emailDomain !== DEAKIN_DOMAIN) {
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
function issueSession(db: Db, user: { _id: string; name: string }) {
const token = generateToken();
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, user._id);
return { userId: user._id, token, name: user.name };
}
async function sendCode(db: Db, email: string, name: string, kind: "verification" | "reset") {
const existing = db
.prepare("SELECT email, createdAt FROM email_verifications WHERE email = ?")
.get(email) as { email: string; createdAt: number } | undefined;
if (existing && Date.now() - existing.createdAt < REQUEST_COOLDOWN_MS) {
throw new Error("A code was just sent. Please wait a minute before trying again.");
}
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 code = generateCode();
const codeHash = hashCode(code);
const now = Date.now();
const expiresAt = now + CODE_TTL_MS;
if (existing) {
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, existing._id);
return { userId: existing._id, token, name: existing.name };
db.prepare(
"UPDATE email_verifications SET name = ?, codeHash = ?, expiresAt = ?, attempts = 0, createdAt = ? WHERE email = ?",
).run(name, codeHash, expiresAt, now, email);
} else {
db.prepare(
"INSERT INTO email_verifications (email, name, codeHash, expiresAt, attempts, createdAt) VALUES (?, ?, ?, ?, 0, ?)",
).run(email, name, codeHash, expiresAt, now);
}
try {
if (kind === "reset") {
await sendPasswordResetCode(email, code);
} else {
await sendVerificationCode(email, code);
}
} catch (err) {
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
throw err;
}
return { ok: true };
}
function verifyAndConsumeCode(db: Db, email: string, code: string): { name: string } {
const pending = db
.prepare(
"SELECT name, codeHash, expiresAt, attempts FROM email_verifications WHERE email = ?",
)
.get(email) as
| { name: string; codeHash: string; expiresAt: number; attempts: number }
| undefined;
if (!pending) throw new Error("No verification code requested for this email");
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 (hashCode(code) !== pending.codeHash) {
db.prepare("UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?").run(
email,
);
throw new Error("Invalid verification code");
}
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
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, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)",
).run(id, email, args.name, token, Date.now());
return { userId: id, token, name: args.name };
"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 }) {
@ -414,7 +568,11 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
type Handler = (db: Db, args: any) => any;
const handlers: Record<string, Handler> = {
"users:register": usersRegister,
"auth:requestCode": authRequestCode,
"auth:signup": authSignup,
"auth:signin": authSignin,
"auth:forgotPassword": authForgotPassword,
"auth:resetPassword": authResetPassword,
"users:getByToken": usersGetByToken,
"topics:getBySlug": topicsGetBySlug,
"topics:getAll": topicsGetAll,
@ -441,8 +599,8 @@ const handlers: Record<string, Handler> = {
"details:getQuestionWithDetails": getQuestionWithDetails,
};
export function call(fn: string, args: Record<string, any> = {}): any {
export async function call(fn: string, args: Record<string, any> = {}): Promise<any> {
const handler = handlers[fn];
if (!handler) throw new Error(`Unknown function: ${fn}`);
return handler(getDb(), args ?? {});
return await handler(getDb(), args ?? {});
}

View file

@ -2,8 +2,9 @@ import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { DATABASE_PATH } from "$env/static/private";
const DB_PATH = resolve(process.env.DATABASE_PATH ?? "data/dsec.db");
const DB_PATH = resolve(DATABASE_PATH ?? "data/dsec.db");
let db: DatabaseSync | null = null;
@ -14,6 +15,7 @@ function createSchema(database: DatabaseSync) {
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
sessionToken TEXT,
passwordHash TEXT,
createdAt INTEGER NOT NULL
);
@ -79,6 +81,15 @@ function createSchema(database: DatabaseSync) {
value INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS email_verifications (
email TEXT PRIMARY KEY,
name TEXT NOT NULL,
codeHash TEXT NOT NULL,
expiresAt INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
createdAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topicId);
CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId);
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt);
@ -194,6 +205,13 @@ const SEED_UNITS = [
{ 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) {
const existing = database.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number };
if (existing.c > 0) return;
@ -223,6 +241,7 @@ export function getDb(): DatabaseSync {
mkdirSync(dirname(DB_PATH), { recursive: true });
db = new DatabaseSync(DB_PATH);
createSchema(db);
migrate(db);
seed(db);
}
return db;

43
src/lib/server/email.ts Normal file
View file

@ -0,0 +1,43 @@
import { Resend } from "resend";
import { RESEND_API_KEY, RESEND_FROM } from "$env/static/private";
const API_KEY = RESEND_API_KEY;
const FROM = RESEND_FROM;
async function send(email: string, subject: string, text: string): Promise<void> {
if (!API_KEY) {
throw new Error("Server error: API key is not configured");
}
if (!FROM) {
throw new Error("Server error: From address is not configured");
}
const resend = new Resend(API_KEY);
const { error } = await resend.emails.send({
from: FROM,
to: email,
subject,
text,
});
if (error) {
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,13 +25,12 @@ export async function initAuth() {
}
}
export async function login(email: string, name: string) {
const result = await mutation("users:register", { email, name });
function setSession(result: { userId: string; token: string; name: string }, email: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token }));
currentUser.set({
_id: result.userId,
email: email.toLowerCase(),
name,
name: result.name,
sessionToken: result.token,
_creationTime: Date.now(),
} as UserDoc);
@ -39,6 +38,29 @@ export async function login(email: string, name: string) {
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 {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;

View file

@ -15,7 +15,7 @@ export const POST: RequestHandler = async ({ request }) => {
}
try {
const result = call(fn, args as Record<string, any>);
const result = await call(fn, args as Record<string, any>);
return json({ ok: true, result });
} catch (err: any) {
const message = err?.message ?? "Internal error";

View file

@ -1,32 +1,136 @@
<script lang="ts">
import { login } from "$lib/stores/auth";
import { goto } from "$app/navigation";
import { forgotPassword, requestCode, resetPassword, signIn, signUp } from "$lib/stores/auth";
type Mode = "signin" | "signup" | "forgot";
let mode = $state<Mode>("signin");
let email = $state("");
let name = $state("");
let code = $state("");
let password = $state("");
let sent = $state(false);
let error = $state("");
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) {
e.preventDefault();
error = "";
if (!email.trim() || !name.trim()) {
if (mode === "signin") {
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";
return;
}
if (!email.toLowerCase().endsWith("@deakin.edu.au")) {
if (!isDeakin(email)) {
error = "Only @deakin.edu.au email addresses are allowed";
return;
}
loading = true;
try {
await login(email.trim(), name.trim());
await requestCode(email.trim(), name.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 verification code";
return;
}
if (password.length < 8) {
error = "Password must be at least 8 characters";
return;
}
loading = true;
try {
await signUp(email.trim(), code.trim(), password);
goto("/");
} catch (err: any) {
error = err.message ?? "Login failed. Please try again.";
error = err.message ?? "Failed to create your account. Please try again.";
} finally {
loading = false;
}
return;
}
// forgot
if (!sent) {
if (!email.trim()) {
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;
}
@ -40,9 +144,70 @@
<div class="page flex min-h-[calc(100vh-220px)] items-center">
<div class="mx-auto w-full max-w-md">
<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>
{#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">
<div>
<label for="name" class="kicker mb-2 block">Full name</label>
@ -74,12 +239,156 @@
{/if}
<button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Signing in..." : "Sign in / Register"}
{loading ? "Sending code..." : "Send verification code"}
</button>
<p class="text-faint text-xs">
By signing in, you agree that your contributions are public.
</p>
<button
type="button"
onclick={() => switchMode("signin")}
class="text-muted hover:text-ink text-sm"
>
Already have an account? 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">Verification 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">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}
<p class="text-primary text-sm">{error}</p>
{/if}
<button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Creating account..." : "Create account"}
</button>
<button
type="button"
onclick={goBack}
class="text-muted hover:text-ink text-sm"
>
Use a different email
</button>
</form>
{/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>