mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 15:43:58 +00:00
allow password creations
This commit is contained in:
parent
9db0df3982
commit
4faf0929b1
7 changed files with 483 additions and 120 deletions
|
|
@ -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>"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
await sendVerificationCode(email, code);
|
if (kind === "reset") {
|
||||||
|
await sendPasswordResetCode(email, code);
|
||||||
|
} else {
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,114 @@
|
||||||
<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 fill in all fields";
|
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 (!isDeakin(email)) {
|
||||||
|
error = "Only @deakin.edu.au email addresses are allowed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!email.toLowerCase().endsWith("@deakin.edu.au")) {
|
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 ?? "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";
|
error = "Only @deakin.edu.au email addresses are allowed";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
await requestCode(email.trim(), name.trim());
|
await forgotPassword(email.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 {
|
||||||
|
|
@ -37,26 +118,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code.trim()) {
|
if (!code.trim()) {
|
||||||
error = "Please enter the verification code";
|
error = "Please enter the reset code";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
error = "Password must be at least 8 characters";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
await verifyCode(email.trim(), code.trim());
|
await resetPassword(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 reset your password. Please try again.";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
step = "email";
|
|
||||||
code = "";
|
|
||||||
error = "";
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -66,23 +144,17 @@
|
||||||
<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">
|
<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>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
bind:value={name}
|
|
||||||
placeholder="Enter your name"
|
|
||||||
class="field"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="kicker mb-2 block">Deakin email</label>
|
<label for="email" class="kicker mb-2 block">Deakin email</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -90,41 +162,20 @@
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
placeholder="@deakin.edu.au"
|
placeholder="@deakin.edu.au"
|
||||||
|
autocomplete="email"
|
||||||
class="field"
|
class="field"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p class="text-faint mt-2 text-xs">Must be an @deakin.edu.au address</p>
|
|
||||||
</div>
|
</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 verification code"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p class="text-faint text-xs">
|
|
||||||
By signing in, you agree that your contributions are public.
|
|
||||||
</p>
|
|
||||||
</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>
|
<div>
|
||||||
<label for="code" class="kicker mb-2 block">Verification code</label>
|
<label for="password" class="kicker mb-2 block">Password</label>
|
||||||
<input
|
<input
|
||||||
id="code"
|
id="password"
|
||||||
type="text"
|
type="password"
|
||||||
inputmode="numeric"
|
bind:value={password}
|
||||||
maxlength="6"
|
placeholder="Your password"
|
||||||
autocomplete="one-time-code"
|
autocomplete="current-password"
|
||||||
bind:value={code}
|
|
||||||
placeholder="000000"
|
|
||||||
class="field"
|
class="field"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -135,13 +186,209 @@
|
||||||
{/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 ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" onclick={goBack} class="text-muted hover:text-ink text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
Use a different email
|
<button
|
||||||
</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>
|
</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>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
class="field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="kicker mb-2 block">Deakin email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
placeholder="@deakin.edu.au"
|
||||||
|
class="field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-faint mt-2 text-xs">Must be an @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 verification code"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue