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. - 💬 **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.
@ -37,13 +37,15 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
npm install npm install
``` ```
2. Configure environment variables (optional): 2. Configure environment variables:
```sh ```sh
cp .env .env.local 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: 3. Start the development server:
@ -56,8 +58,10 @@ A centralised resource hub for Deakin University students studying **SIT** (IT,
## Environment variables ## Environment variables
| Variable | Description | Default | | Variable | Description | Default |
| --------------- | --------------------------------------- | -------------- | | ---------------- | --------------------------------------- | --------------------------------------- |
| `DATABASE_PATH` | Path to the SQLite database file | `data/dsec.db` | | `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` | | `HOST` | Host the Node server binds to (build) | `0.0.0.0` |
| `PORT` | Port the Node server listens on (build) | `3000` | | `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 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 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 ## Data model
@ -127,6 +131,7 @@ The SQLite database contains the following tables:
- `questions` — student questions - `questions` — student questions
- `comments` — note comments and question answers - `comments` — note comments and question answers
- `votes` — upvotes/downvotes on notes and questions - `votes` — upvotes/downvotes on notes and questions
- `email_verifications` — pending email verification codes
## Disclaimer ## Disclaimer

View file

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

52
package-lock.json generated
View file

@ -7,6 +7,9 @@
"": { "": {
"name": "dsec-notebook", "name": "dsec-notebook",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"resend": "^6.25.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
@ -1242,6 +1245,12 @@
"win32" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -2065,6 +2074,12 @@
"node": ">=12.0.0" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2640,6 +2655,12 @@
"node": ">=14.19.0" "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": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@ -2683,6 +2704,27 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -2843,6 +2885,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/std-env": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",

View file

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

View file

@ -1,7 +1,18 @@
import { getDb } from "./db"; 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 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>; type Db = ReturnType<typeof getDb>;
@ -18,6 +29,45 @@ function generateToken(): string {
return result; 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> { function requireAuth(db: Db, token: string): Record<string, any> {
const user = db const user = db
.prepare( .prepare(
@ -34,31 +84,135 @@ function mapQuestion(row: Record<string, any>): Record<string, any> {
// ---- users ---- // ---- users ----
function usersRegister(db: Db, args: { email: string; name: string }) { function issueSession(db: Db, user: { _id: string; name: string }) {
const email = args.email.toLowerCase(); const token = generateToken();
const emailDomain = email.split("@")[1]?.toLowerCase(); db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, user._id);
if (emailDomain !== DEAKIN_DOMAIN) { return { userId: user._id, token, name: user.name };
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`); }
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 const code = generateCode();
.prepare( const codeHash = hashCode(code);
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?", const now = Date.now();
) const expiresAt = now + CODE_TTL_MS;
.get(email) as Record<string, any> | undefined;
const token = generateToken();
if (existing) { if (existing) {
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, existing._id); db.prepare(
return { userId: existing._id, token, name: existing.name }; "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(); const id = newId();
db.prepare( db.prepare(
"INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)", "INSERT INTO users (id, email, name, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)",
).run(id, email, args.name, token, Date.now()); ).run(id, email, pending.name, hashPassword(password), Date.now());
return { userId: id, token, name: args.name };
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 }) {
@ -414,7 +568,11 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
type Handler = (db: Db, args: any) => any; type Handler = (db: Db, args: any) => any;
const handlers: Record<string, Handler> = { 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, "users:getByToken": usersGetByToken,
"topics:getBySlug": topicsGetBySlug, "topics:getBySlug": topicsGetBySlug,
"topics:getAll": topicsGetAll, "topics:getAll": topicsGetAll,
@ -441,8 +599,8 @@ const handlers: Record<string, Handler> = {
"details:getQuestionWithDetails": getQuestionWithDetails, "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]; const handler = handlers[fn];
if (!handler) throw new Error(`Unknown function: ${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 { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { randomUUID } from "node:crypto"; 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; let db: DatabaseSync | null = null;
@ -14,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
); );
@ -79,6 +81,15 @@ function createSchema(database: DatabaseSync) {
value INTEGER NOT NULL 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_topic ON notes(topicId);
CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId); CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId);
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt); CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt);
@ -194,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;
@ -223,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;

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) { function setSession(result: { userId: string; token: string; name: string }, email: string) {
const result = await mutation("users:register", { email, name });
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,
email: email.toLowerCase(), email: email.toLowerCase(),
name, name: result.name,
sessionToken: result.token, sessionToken: result.token,
_creationTime: Date.now(), _creationTime: Date.now(),
} as UserDoc); } as UserDoc);
@ -39,6 +38,29 @@ export async function login(email: string, name: 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

@ -15,7 +15,7 @@ export const POST: RequestHandler = async ({ request }) => {
} }
try { 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 }); return json({ ok: true, result });
} catch (err: any) { } catch (err: any) {
const message = err?.message ?? "Internal error"; const message = err?.message ?? "Internal error";

View file

@ -1,32 +1,136 @@
<script lang="ts"> <script lang="ts">
import { login } from "$lib/stores/auth";
import { goto } from "$app/navigation"; 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 email = $state("");
let name = $state(""); let name = $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 (!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"; 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 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("/"); goto("/");
} catch (err: any) { } 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 { } finally {
loading = false; loading = false;
} }
@ -40,9 +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 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>
@ -74,12 +239,156 @@
{/if} {/if}
<button type="submit" disabled={loading} class="btn-primary w-full"> <button type="submit" disabled={loading} class="btn-primary w-full">
{loading ? "Signing in..." : "Sign in / Register"} {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}
<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>
</div> </div>