diff --git a/apps/api/package.json b/apps/api/package.json index eea57f6..592fd06 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,7 +27,7 @@ "@elysia/jwt": "^1.4.2", "@elysia/server-timing": "^1.4.1", "@elysiajs/cors": "^1.4.2", - "@types/pg": "^8.21.0", + "@types/pg": "^8.23.1", "elysia": "^1.4.29", "kysely": "^0.29.5", "ml-levenberg-marquardt": "^5.1.0", diff --git a/apps/api/src/database/migrations/2026-06-14.ts b/apps/api/src/database/migrations/2026-06-14.ts index c93f2c6..4e011e4 100644 --- a/apps/api/src/database/migrations/2026-06-14.ts +++ b/apps/api/src/database/migrations/2026-06-14.ts @@ -11,6 +11,8 @@ export async function up(db: Kysely): Promise { ) .addColumn("discord_id", "text", (cb) => cb.unique()) .addColumn("email", "text", (cb) => cb.unique()) + .addColumn("password_hash", "text") + .addColumn("role", "text", (cb) => cb.notNull().defaultTo("user")) .addColumn("name", "text", (cb) => cb.notNull().defaultTo("Anonymous")) .addColumn("gender", "text") .execute(); diff --git a/apps/api/src/database/seed.ts b/apps/api/src/database/seed.ts index 36a0652..2ff759c 100644 --- a/apps/api/src/database/seed.ts +++ b/apps/api/src/database/seed.ts @@ -1,11 +1,11 @@ import { StandardsConfigSchema, } from "@blade-and-brawn/calculator"; -import { Gender } from "@blade-and-brawn/domain"; import { Value } from "@sinclair/typebox/value"; import { db } from "./db"; import { DEFAULT_NAME, env, log } from "../util"; import standardsConfig from "./seed-data/standards-config.json" with {type: "json"}; +import type { AccountRole } from "../services/accounts"; Value.Assert(StandardsConfigSchema, standardsConfig); @@ -69,44 +69,26 @@ async function seedCalculator(standardsConfigId: string): Promise { log.info({ id: result.id }, "seeded default calculator"); } -async function seedAccounts(): Promise { - const ACCOUNT_NAME = "Xominus"; - +async function seedAdminAccount(): Promise { const existing = await db.selectFrom("accounts") .select(["id"]) - .where("name", "=", ACCOUNT_NAME) + .where("email", "=", env.PUBLIC_ADMIN_EMAIL) .executeTakeFirst(); if (existing) { - log.info({ id: existing.id }, "default account already seeded, skipping"); + log.info({ id: existing.id }, "admin account already seeded, skipping"); return; } const account = await db.insertInto("accounts") .values({ - name: ACCOUNT_NAME, - discord_id: "1325552430942916731", - email: "xominus@bladeandbrawn.com" + name: "Admin", + email: env.PUBLIC_ADMIN_EMAIL, + role: "admin" satisfies AccountRole, + password_hash: env.ADMIN_PASSWORD_HASH }) .returning("id") .executeTakeFirstOrThrow(); - log.info({ id: account.id }, "seeded default account"); - - await db.insertInto("assessments") - .values({ - account_id: account.id, - name: ACCOUNT_NAME, - gender: Gender.Male, - age: 25, - weight: 80, - perf_back_squat: 72.57472, - perf_deadlift: 127.00576, - perf_bench_press: 102.0582, - perf_run: 446000, - perf_broad_jump: 185.42000000000002, - perf_cone_drill: 9870, - }) - .execute(); - log.info({ accountId: account.id }, "seeded placeholder assessment"); + log.info({ id: account.id }, "seeded admin account"); } (async () => { @@ -121,7 +103,7 @@ async function seedAccounts(): Promise { const datasetId = await seedStandardsDataset(); const standardsConfigId = await seedStandardsConfig(datasetId); await seedCalculator(standardsConfigId); - await seedAccounts(); + await seedAdminAccount(); log.info("seed finished"); } catch (err) { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 241c2d2..11b9e30 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -20,7 +20,7 @@ import { CalculatorService, CalculatorUnavailableError } from "./services/calcul import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsService } from "./services/standards"; import { EventsService, EventStatusSchema } from "./services/events"; -import { AccountsService } from "./services/accounts"; +import { AccountsService, type AccountRole } from "./services/accounts"; import { Value } from "@sinclair/typebox/value"; // CONSTANTS @@ -56,15 +56,15 @@ const authPlugin = new Elysia({ name: "auth" }) authAdmin: { async resolve({ jwt, cookie: { auth } }) { const token = auth.value && await jwt.verify(auth.value); - if (!token || token.role !== "admin" || !token.sessionId) throw status(401, "Unauthorized"); - return { sessionId: token.sessionId.toString() }; + if (!token || token.role !== "admin" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); + return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; } }, - authUser: { + auth: { async resolve({ jwt, cookie: { auth } }) { const token = auth.value && await jwt.verify(auth.value); - if (!token || token.role !== "user" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); - return { sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; + if (!token || !token.role || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); + return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; } } }); @@ -137,22 +137,27 @@ export const app = new Elysia() .get("/health", () => ({ status: "ok" })) // AUTHENTICATION - .post("/auth/admin/login", async ({ jwt, body, cookie: { auth } }) => { - const match = crypto.timingSafeEqual(sha256Sum(body.password), Buffer.from(env.ADMIN_PASSWORD, "hex")); - if (!match) throw status(401, "Invalid credentials"); + .post("/auth/login", async ({ jwt, body: { password, email }, cookie: { auth } }) => { + const account = await s.Accounts.getByEmail(email); + const password_match = crypto.timingSafeEqual( + sha256Sum(password), + account?.password_hash ? Buffer.from(account?.password_hash, "hex") : sha256Sum("Dummy") + ); + + if (!account?.password_hash || !password_match) throw status(401, "Invalid credentials"); auth.set({ - value: await jwt.sign({ role: "admin", sessionId: randomUUIDv7(), exp: JWT_EXP }), + value: await jwt.sign({ role: account.role, sessionId: randomUUIDv7(), accountId: account.id, exp: JWT_EXP }), path: "/", maxAge: JWT_EXP_SECONDS, - sameSite: "lax", + sameSite: env.NODE_ENV === "production" ? "lax" : "none", httpOnly: true, - secure: env.NODE_ENV === "production", + secure: true, domain: env.NODE_ENV === "production" ? ".bladeandbrawn.com" : undefined }); - }, { body: t.Object({ password: t.String() }) }) + }, { body: t.Object({ email: t.String(), password: t.String() }) }) .group("/auth/discord", (app) => app .get("/login", async ({ cookie: { authDiscord } }) => { @@ -226,7 +231,7 @@ export const app = new Elysia() // Authenticate auth.set({ - value: await jwt.sign({ role: "user", sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }), + value: await jwt.sign({ role: "user" satisfies AccountRole, sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }), path: "/", maxAge: JWT_EXP_SECONDS, sameSite: env.NODE_ENV === "production" ? "lax" : "none", @@ -457,7 +462,7 @@ export const app = new Elysia() }, { params: t.Object({ id: t.String() }) }) - .guard({ authUser: true }, (app) => app + .guard({ auth: true }, (app) => app .get("/me/stats", async ({ accountId }) => { const stats = await s.Accounts.stats(accountId); if (!stats) throw new NotFoundError("Account stats not found"); diff --git a/apps/api/src/services/accounts.ts b/apps/api/src/services/accounts.ts index fb6c30b..dab83fd 100644 --- a/apps/api/src/services/accounts.ts +++ b/apps/api/src/services/accounts.ts @@ -1,9 +1,11 @@ -import { Activity, Gender, type Player } from "@blade-and-brawn/domain"; +import { Activity, Gender } from "@blade-and-brawn/domain"; import { db } from "../database/db"; import type { CalculatorService } from "./calculator"; import { Value } from "@sinclair/typebox/value"; import { t } from "elysia"; +export type AccountRole = "user" | "admin"; + export class AccountsService { constructor(private Calculator: CalculatorService) { } @@ -26,11 +28,18 @@ export class AccountsService { async get(id: string) { return await (db).selectFrom("accounts") - .select(["id", "discord_id", "name", "email", "gender"]) + .select(["id", "discord_id", "name", "email", "gender", "role"]) .where(...AccountsService.idComparison(id)) .executeTakeFirst(); } + async getByEmail(email: string) { + return await (db).selectFrom("accounts") + .select(["id", "discord_id", "name", "email", "gender", "password_hash", "role"]) + .where("email", "=", email) + .executeTakeFirst(); + } + async stats(id: string) { const latestAssessment = await db.selectFrom("accounts") .innerJoin("assessments", "assessments.account_id", "accounts.id") diff --git a/apps/api/src/util.ts b/apps/api/src/util.ts index bd100e8..7f7538d 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -20,7 +20,8 @@ export const env = { WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"), WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"), AUTH_SECRET: requireEnv("AUTH_SECRET"), - ADMIN_PASSWORD: requireEnv("ADMIN_PASSWORD"), + PUBLIC_ADMIN_EMAIL: requireEnv("PUBLIC_ADMIN_EMAIL"), + ADMIN_PASSWORD_HASH: requireEnv("ADMIN_PASSWORD_HASH"), DATABASE_URL: requireEnv("DATABASE_URL"), DATABASE_POOL_MAX: requireEnv("DATABASE_POOL_MAX"), MAX_WORKER_COUNT: requireEnv("MAX_WORKER_COUNT"), diff --git a/apps/portal/package.json b/apps/portal/package.json index 1cb954c..76daa92 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -3,10 +3,10 @@ "type": "module", "devDependencies": { "@blade-and-brawn/api": "workspace:*", - "@sveltejs/kit": "^2.70.2", + "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^6.2.4", "elysia": "^1.4.29", - "svelte": "^5.56.9", + "svelte": "^5.56.10", "svelte-adapter-bun": "^1.0.1", "svelte-check": "^4.7.6", "vite": "^7.3.6" @@ -25,7 +25,7 @@ "@blade-and-brawn/domain": "workspace:*", "@elysia/eden": "^1.4.10", "@tailwindcss/vite": "^4.3.3", - "daisyui": "^5.7.17", + "daisyui": "^5.7.20", "jose": "^6.2.9", "tailwindcss": "^4.3.3" } diff --git a/apps/portal/src/routes/login/+page.svelte b/apps/portal/src/routes/login/+page.svelte index 9892edf..c626fe9 100644 --- a/apps/portal/src/routes/login/+page.svelte +++ b/apps/portal/src/routes/login/+page.svelte @@ -2,6 +2,7 @@ import "../app.css"; import { goto } from "$app/navigation"; import { api } from "$lib/api"; + import { env } from "$env/dynamic/public"; let error = $state(""); let loading = $state(false); @@ -14,7 +15,10 @@ loading = true; try { - const { status } = await api.auth.admin.login.post({ password }); + const { status } = await api.auth.login.post({ + email: env.PUBLIC_ADMIN_EMAIL ?? "", + password, + }); if (status === 401) { error = "Invalid password";