mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
added auth with Resend env vars
This commit is contained in:
parent
ffa6b84da3
commit
9db0df3982
11 changed files with 327 additions and 69 deletions
8
.env.example
Normal file
8
.env.example
Normal 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>
|
||||||
13
README.md
13
README.md
|
|
@ -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: 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`.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
52
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { randomUUID } from "node:crypto";
|
import { createHash, randomInt, randomUUID } from "node:crypto";
|
||||||
|
import { 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 +22,23 @@ 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 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,13 +55,7 @@ function mapQuestion(row: Record<string, any>): Record<string, any> {
|
||||||
|
|
||||||
// ---- users ----
|
// ---- users ----
|
||||||
|
|
||||||
function usersRegister(db: Db, args: { email: string; name: string }) {
|
function createOrReuseUser(db: Db, 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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?",
|
"SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?",
|
||||||
|
|
@ -50,15 +65,84 @@ function usersRegister(db: Db, args: { email: string; name: string }) {
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, existing._id);
|
db.prepare("UPDATE users SET sessionToken = ?, name = ? WHERE id = ?").run(
|
||||||
return { userId: existing._id, token, name: existing.name };
|
token,
|
||||||
|
name,
|
||||||
|
existing._id,
|
||||||
|
);
|
||||||
|
return { userId: existing._id, token, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
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, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)",
|
||||||
).run(id, email, args.name, token, Date.now());
|
).run(id, email, name, token, Date.now());
|
||||||
return { userId: id, token, name: args.name };
|
return { userId: id, token, 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");
|
||||||
|
}
|
||||||
|
const name = args.name.trim();
|
||||||
|
|
||||||
|
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 code = generateCode();
|
||||||
|
const codeHash = hashCode(code);
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + CODE_TTL_MS;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
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 {
|
||||||
|
await sendVerificationCode(email, code);
|
||||||
|
} catch (err) {
|
||||||
|
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authVerifyCode(db: Db, args: { email: string; code: string }) {
|
||||||
|
const email = normalizeEmail(args.email);
|
||||||
|
|
||||||
|
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(args.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 createOrReuseUser(db, email, pending.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function usersGetByToken(db: Db, args: { token: string }) {
|
function usersGetByToken(db: Db, args: { token: string }) {
|
||||||
|
|
@ -414,7 +498,8 @@ 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:verifyCode": authVerifyCode,
|
||||||
"users:getByToken": usersGetByToken,
|
"users:getByToken": usersGetByToken,
|
||||||
"topics:getBySlug": topicsGetBySlug,
|
"topics:getBySlug": topicsGetBySlug,
|
||||||
"topics:getAll": topicsGetAll,
|
"topics:getAll": topicsGetAll,
|
||||||
|
|
@ -441,8 +526,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 ?? {});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -79,6 +80,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);
|
||||||
|
|
|
||||||
27
src/lib/server/email.ts
Normal file
27
src/lib/server/email.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Resend } from "resend";
|
||||||
|
import { RESEND_API_KEY, RESEND_FROM } from "$env/static/private";
|
||||||
|
|
||||||
|
const API_KEY = RESEND_API_KEY;
|
||||||
|
const FROM = RESEND_FROM;
|
||||||
|
|
||||||
|
export async function sendVerificationCode(email: string, code: string): Promise<void> {
|
||||||
|
if (!API_KEY) {
|
||||||
|
throw new Error("Server error: API error is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FROM) {
|
||||||
|
throw new Error("Server error: Email is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resend = new Resend(API_KEY);
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: email,
|
||||||
|
subject: "Your DSEC Notebook verification code",
|
||||||
|
text: `Your verification code is ${code}. It expires in 10 minutes.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,13 +25,17 @@ export async function initAuth() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email: string, name: string) {
|
export async function requestCode(email: string, name: string) {
|
||||||
const result = await mutation("users:register", { email, name });
|
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,
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { login } from "$lib/stores/auth";
|
import { requestCode, verifyCode } from "$lib/stores/auth";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
let step = $state<"email" | "code">("email");
|
||||||
let email = $state("");
|
let email = $state("");
|
||||||
let name = $state("");
|
let name = $state("");
|
||||||
|
let code = $state("");
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
|
|
@ -11,6 +13,7 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = "";
|
error = "";
|
||||||
|
|
||||||
|
if (step === "email") {
|
||||||
if (!email.trim() || !name.trim()) {
|
if (!email.trim() || !name.trim()) {
|
||||||
error = "Please fill in all fields";
|
error = "Please fill in all fields";
|
||||||
return;
|
return;
|
||||||
|
|
@ -23,13 +26,36 @@
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
await login(email.trim(), name.trim());
|
await requestCode(email.trim(), name.trim());
|
||||||
goto("/");
|
step = "code";
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message ?? "Login failed. Please try again.";
|
error = err.message ?? "Failed to send the code. Please try again.";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code.trim()) {
|
||||||
|
error = "Please enter the verification code";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
await verifyCode(email.trim(), code.trim());
|
||||||
|
goto("/");
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message ?? "Verification failed. Please try again.";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
step = "email";
|
||||||
|
code = "";
|
||||||
|
error = "";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -43,6 +69,7 @@
|
||||||
<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">Sign in</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"}
|
||||||
<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 +101,47 @@
|
||||||
{/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">
|
<p class="text-faint text-xs">
|
||||||
By signing in, you agree that your contributions are public.
|
By signing in, you agree that your contributions are public.
|
||||||
</p>
|
</p>
|
||||||
</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>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-primary text-sm">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} class="btn-primary w-full">
|
||||||
|
{loading ? "Verifying..." : "Verify & sign in"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" onclick={goBack} class="text-muted hover:text-ink text-sm">
|
||||||
|
Use a different email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue