Seed admin acccount and generalize login/auth routes
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
"@elysia/jwt": "^1.4.2",
|
"@elysia/jwt": "^1.4.2",
|
||||||
"@elysia/server-timing": "^1.4.1",
|
"@elysia/server-timing": "^1.4.1",
|
||||||
"@elysiajs/cors": "^1.4.2",
|
"@elysiajs/cors": "^1.4.2",
|
||||||
"@types/pg": "^8.21.0",
|
"@types/pg": "^8.23.1",
|
||||||
"elysia": "^1.4.29",
|
"elysia": "^1.4.29",
|
||||||
"kysely": "^0.29.5",
|
"kysely": "^0.29.5",
|
||||||
"ml-levenberg-marquardt": "^5.1.0",
|
"ml-levenberg-marquardt": "^5.1.0",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
)
|
)
|
||||||
.addColumn("discord_id", "text", (cb) => cb.unique())
|
.addColumn("discord_id", "text", (cb) => cb.unique())
|
||||||
.addColumn("email", "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("name", "text", (cb) => cb.notNull().defaultTo("Anonymous"))
|
||||||
.addColumn("gender", "text")
|
.addColumn("gender", "text")
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
StandardsConfigSchema,
|
StandardsConfigSchema,
|
||||||
} from "@blade-and-brawn/calculator";
|
} from "@blade-and-brawn/calculator";
|
||||||
import { Gender } from "@blade-and-brawn/domain";
|
|
||||||
import { Value } from "@sinclair/typebox/value";
|
import { Value } from "@sinclair/typebox/value";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { DEFAULT_NAME, env, log } from "../util";
|
import { DEFAULT_NAME, env, log } from "../util";
|
||||||
import standardsConfig from "./seed-data/standards-config.json" with {type: "json"};
|
import standardsConfig from "./seed-data/standards-config.json" with {type: "json"};
|
||||||
|
import type { AccountRole } from "../services/accounts";
|
||||||
|
|
||||||
Value.Assert(StandardsConfigSchema, standardsConfig);
|
Value.Assert(StandardsConfigSchema, standardsConfig);
|
||||||
|
|
||||||
@@ -69,44 +69,26 @@ async function seedCalculator(standardsConfigId: string): Promise<void> {
|
|||||||
log.info({ id: result.id }, "seeded default calculator");
|
log.info({ id: result.id }, "seeded default calculator");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedAccounts(): Promise<void> {
|
async function seedAdminAccount(): Promise<void> {
|
||||||
const ACCOUNT_NAME = "Xominus";
|
|
||||||
|
|
||||||
const existing = await db.selectFrom("accounts")
|
const existing = await db.selectFrom("accounts")
|
||||||
.select(["id"])
|
.select(["id"])
|
||||||
.where("name", "=", ACCOUNT_NAME)
|
.where("email", "=", env.PUBLIC_ADMIN_EMAIL)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
log.info({ id: existing.id }, "default account already seeded, skipping");
|
log.info({ id: existing.id }, "admin account already seeded, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await db.insertInto("accounts")
|
const account = await db.insertInto("accounts")
|
||||||
.values({
|
.values({
|
||||||
name: ACCOUNT_NAME,
|
name: "Admin",
|
||||||
discord_id: "1325552430942916731",
|
email: env.PUBLIC_ADMIN_EMAIL,
|
||||||
email: "xominus@bladeandbrawn.com"
|
role: "admin" satisfies AccountRole,
|
||||||
|
password_hash: env.ADMIN_PASSWORD_HASH
|
||||||
})
|
})
|
||||||
.returning("id")
|
.returning("id")
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
log.info({ id: account.id }, "seeded default account");
|
log.info({ id: account.id }, "seeded admin 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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -121,7 +103,7 @@ async function seedAccounts(): Promise<void> {
|
|||||||
const datasetId = await seedStandardsDataset();
|
const datasetId = await seedStandardsDataset();
|
||||||
const standardsConfigId = await seedStandardsConfig(datasetId);
|
const standardsConfigId = await seedStandardsConfig(datasetId);
|
||||||
await seedCalculator(standardsConfigId);
|
await seedCalculator(standardsConfigId);
|
||||||
await seedAccounts();
|
await seedAdminAccount();
|
||||||
log.info("seed finished");
|
log.info("seed finished");
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|||||||
+20
-15
@@ -20,7 +20,7 @@ import { CalculatorService, CalculatorUnavailableError } from "./services/calcul
|
|||||||
import { StandardsParamsSchema } from "@blade-and-brawn/calculator";
|
import { StandardsParamsSchema } from "@blade-and-brawn/calculator";
|
||||||
import { StandardsService } from "./services/standards";
|
import { StandardsService } from "./services/standards";
|
||||||
import { EventsService, EventStatusSchema } from "./services/events";
|
import { EventsService, EventStatusSchema } from "./services/events";
|
||||||
import { AccountsService } from "./services/accounts";
|
import { AccountsService, type AccountRole } from "./services/accounts";
|
||||||
import { Value } from "@sinclair/typebox/value";
|
import { Value } from "@sinclair/typebox/value";
|
||||||
|
|
||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
@@ -56,15 +56,15 @@ const authPlugin = new Elysia({ name: "auth" })
|
|||||||
authAdmin: {
|
authAdmin: {
|
||||||
async resolve({ jwt, cookie: { auth } }) {
|
async resolve({ jwt, cookie: { auth } }) {
|
||||||
const token = auth.value && await jwt.verify(auth.value);
|
const token = auth.value && await jwt.verify(auth.value);
|
||||||
if (!token || token.role !== "admin" || !token.sessionId) throw status(401, "Unauthorized");
|
if (!token || token.role !== "admin" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized");
|
||||||
return { sessionId: token.sessionId.toString() };
|
return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
authUser: {
|
auth: {
|
||||||
async resolve({ jwt, cookie: { auth } }) {
|
async resolve({ jwt, cookie: { auth } }) {
|
||||||
const token = auth.value && await jwt.verify(auth.value);
|
const token = auth.value && await jwt.verify(auth.value);
|
||||||
if (!token || token.role !== "user" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized");
|
if (!token || !token.role || !token.accountId || !token.sessionId) throw status(401, "Unauthorized");
|
||||||
return { sessionId: token.sessionId.toString(), accountId: token.accountId.toString() };
|
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" }))
|
.get("/health", () => ({ status: "ok" }))
|
||||||
|
|
||||||
// AUTHENTICATION
|
// AUTHENTICATION
|
||||||
.post("/auth/admin/login", async ({ jwt, body, cookie: { auth } }) => {
|
.post("/auth/login", async ({ jwt, body: { password, email }, cookie: { auth } }) => {
|
||||||
const match = crypto.timingSafeEqual(sha256Sum(body.password), Buffer.from(env.ADMIN_PASSWORD, "hex"));
|
const account = await s.Accounts.getByEmail(email);
|
||||||
if (!match) throw status(401, "Invalid credentials");
|
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({
|
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: "/",
|
path: "/",
|
||||||
maxAge: JWT_EXP_SECONDS,
|
maxAge: JWT_EXP_SECONDS,
|
||||||
sameSite: "lax",
|
sameSite: env.NODE_ENV === "production" ? "lax" : "none",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: true,
|
||||||
domain: env.NODE_ENV === "production" ?
|
domain: env.NODE_ENV === "production" ?
|
||||||
".bladeandbrawn.com" :
|
".bladeandbrawn.com" :
|
||||||
undefined
|
undefined
|
||||||
});
|
});
|
||||||
}, { body: t.Object({ password: t.String() }) })
|
}, { body: t.Object({ email: t.String(), password: t.String() }) })
|
||||||
|
|
||||||
.group("/auth/discord", (app) => app
|
.group("/auth/discord", (app) => app
|
||||||
.get("/login", async ({ cookie: { authDiscord } }) => {
|
.get("/login", async ({ cookie: { authDiscord } }) => {
|
||||||
@@ -226,7 +231,7 @@ export const app = new Elysia()
|
|||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
auth.set({
|
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: "/",
|
path: "/",
|
||||||
maxAge: JWT_EXP_SECONDS,
|
maxAge: JWT_EXP_SECONDS,
|
||||||
sameSite: env.NODE_ENV === "production" ? "lax" : "none",
|
sameSite: env.NODE_ENV === "production" ? "lax" : "none",
|
||||||
@@ -457,7 +462,7 @@ export const app = new Elysia()
|
|||||||
}, {
|
}, {
|
||||||
params: t.Object({ id: t.String() })
|
params: t.Object({ id: t.String() })
|
||||||
})
|
})
|
||||||
.guard({ authUser: true }, (app) => app
|
.guard({ auth: true }, (app) => app
|
||||||
.get("/me/stats", async ({ accountId }) => {
|
.get("/me/stats", async ({ accountId }) => {
|
||||||
const stats = await s.Accounts.stats(accountId);
|
const stats = await s.Accounts.stats(accountId);
|
||||||
if (!stats) throw new NotFoundError("Account stats not found");
|
if (!stats) throw new NotFoundError("Account stats not found");
|
||||||
|
|||||||
@@ -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 { db } from "../database/db";
|
||||||
import type { CalculatorService } from "./calculator";
|
import type { CalculatorService } from "./calculator";
|
||||||
import { Value } from "@sinclair/typebox/value";
|
import { Value } from "@sinclair/typebox/value";
|
||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
|
|
||||||
|
export type AccountRole = "user" | "admin";
|
||||||
|
|
||||||
export class AccountsService {
|
export class AccountsService {
|
||||||
constructor(private Calculator: CalculatorService) { }
|
constructor(private Calculator: CalculatorService) { }
|
||||||
|
|
||||||
@@ -26,11 +28,18 @@ export class AccountsService {
|
|||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
return await (db).selectFrom("accounts")
|
return await (db).selectFrom("accounts")
|
||||||
.select(["id", "discord_id", "name", "email", "gender"])
|
.select(["id", "discord_id", "name", "email", "gender", "role"])
|
||||||
.where(...AccountsService.idComparison(id))
|
.where(...AccountsService.idComparison(id))
|
||||||
.executeTakeFirst();
|
.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) {
|
async stats(id: string) {
|
||||||
const latestAssessment = await db.selectFrom("accounts")
|
const latestAssessment = await db.selectFrom("accounts")
|
||||||
.innerJoin("assessments", "assessments.account_id", "accounts.id")
|
.innerJoin("assessments", "assessments.account_id", "accounts.id")
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const env = {
|
|||||||
WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"),
|
WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"),
|
||||||
WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"),
|
WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"),
|
||||||
AUTH_SECRET: requireEnv("AUTH_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_URL: requireEnv("DATABASE_URL"),
|
||||||
DATABASE_POOL_MAX: requireEnv("DATABASE_POOL_MAX"),
|
DATABASE_POOL_MAX: requireEnv("DATABASE_POOL_MAX"),
|
||||||
MAX_WORKER_COUNT: requireEnv("MAX_WORKER_COUNT"),
|
MAX_WORKER_COUNT: requireEnv("MAX_WORKER_COUNT"),
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blade-and-brawn/api": "workspace:*",
|
"@blade-and-brawn/api": "workspace:*",
|
||||||
"@sveltejs/kit": "^2.70.2",
|
"@sveltejs/kit": "^2.70.3",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"elysia": "^1.4.29",
|
"elysia": "^1.4.29",
|
||||||
"svelte": "^5.56.9",
|
"svelte": "^5.56.10",
|
||||||
"svelte-adapter-bun": "^1.0.1",
|
"svelte-adapter-bun": "^1.0.1",
|
||||||
"svelte-check": "^4.7.6",
|
"svelte-check": "^4.7.6",
|
||||||
"vite": "^7.3.6"
|
"vite": "^7.3.6"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@blade-and-brawn/domain": "workspace:*",
|
"@blade-and-brawn/domain": "workspace:*",
|
||||||
"@elysia/eden": "^1.4.10",
|
"@elysia/eden": "^1.4.10",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"daisyui": "^5.7.17",
|
"daisyui": "^5.7.20",
|
||||||
"jose": "^6.2.9",
|
"jose": "^6.2.9",
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { api } from "$lib/api";
|
import { api } from "$lib/api";
|
||||||
|
import { env } from "$env/dynamic/public";
|
||||||
|
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -14,7 +15,10 @@
|
|||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
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) {
|
if (status === 401) {
|
||||||
error = "Invalid password";
|
error = "Invalid password";
|
||||||
|
|||||||
Reference in New Issue
Block a user