feat: add BetterAuth authentication

- Add better-auth to backend and frontend
- Create auth tables (users, sessions, accounts, verifications)
- Mount BetterAuth handler on /api/auth/*
- Protect GET /api/tasks with session auth
- Add login page with email/password
- Add invite route for creating users
- Add logout button to header
- Cross-subdomain cookies for .donovankelly.xyz
- Fix page title to 'Hammer Queue'
- Keep bearer token for admin mutations (separate from session auth)
- Update docker-compose with BETTER_AUTH_SECRET and COOKIE_DOMAIN
This commit is contained in:
2026-01-28 23:19:52 +00:00
parent 52b6190d43
commit 96d81520b9
16 changed files with 408 additions and 42 deletions

View File

@@ -6,6 +6,7 @@ import {
timestamp,
jsonb,
pgEnum,
boolean,
} from "drizzle-orm/pg-core";
export const taskStatusEnum = pgEnum("task_status", [
@@ -53,3 +54,51 @@ export const tasks = pgTable("tasks", {
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
// ─── BetterAuth tables ───
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id").notNull().references(() => users.id),
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => users.id),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});

View File

@@ -1,11 +1,73 @@
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { taskRoutes } from "./routes/tasks";
import { auth } from "./lib/auth";
const PORT = process.env.PORT || 3100;
const app = new Elysia()
.use(cors())
.use(
cors({
origin: ["https://queue.donovankelly.xyz", "http://localhost:5173"],
credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
)
// Mount BetterAuth handler
.all("/api/auth/*", async ({ request }) => {
return auth.handler(request);
})
// Invite route - create a user (bearer token or session auth)
.post("/api/invite", async ({ request, headers, body }) => {
const bearerToken = process.env.API_BEARER_TOKEN || "hammer-dev-token";
const authHeader = headers["authorization"];
// Check bearer token first
let authorized = authHeader === `Bearer ${bearerToken}`;
// If no bearer token, check session
if (!authorized) {
const session = await auth.api.getSession({ headers: request.headers });
authorized = !!session;
}
if (!authorized) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { email, password, name } = body as {
email: string;
password: string;
name: string;
};
if (!email || !password || !name) {
return new Response(
JSON.stringify({ error: "email, password, and name are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
try {
const user = await auth.api.signUpEmail({
body: { email, password, name },
});
return new Response(JSON.stringify({ success: true, user }), {
headers: { "Content-Type": "application/json" },
});
} catch (e: any) {
return new Response(
JSON.stringify({ error: e.message || "Failed to create user" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
})
.use(taskRoutes)
.get("/health", () => ({ status: "ok", service: "hammer-queue" }))
.onError(({ error, set }) => {

32
backend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db";
import * as schema from "../db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verifications,
},
}),
emailAndPassword: {
enabled: true,
},
advanced: {
disableCSRFCheck: false,
cookiePrefix: "hammer-queue",
crossSubDomainCookies: {
enabled: true,
domain: process.env.COOKIE_DOMAIN || ".donovankelly.xyz",
},
},
trustedOrigins: [
"https://queue.donovankelly.xyz",
"http://localhost:5173",
],
secret: process.env.BETTER_AUTH_SECRET,
});

View File

@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
import { db } from "../db";
import { tasks, type ProgressNote } from "../db/schema";
import { eq, asc, desc, sql, inArray } from "drizzle-orm";
import { auth } from "../lib/auth";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
@@ -14,16 +15,27 @@ const statusOrder = sql`CASE
WHEN ${tasks.status} = 'cancelled' THEN 4
ELSE 5 END`;
function requireAuth(headers: Record<string, string | undefined>) {
const auth = headers["authorization"];
if (!auth || auth !== `Bearer ${BEARER_TOKEN}`) {
function requireBearerAuth(headers: Record<string, string | undefined>) {
const authHeader = headers["authorization"];
if (!authHeader || authHeader !== `Bearer ${BEARER_TOKEN}`) {
throw new Error("Unauthorized");
}
}
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
// Check bearer token first
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
// Check session
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw new Error("Unauthorized");
}
export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
// GET all tasks - public (read-only dashboard)
.get("/", async () => {
// GET all tasks - requires session or bearer auth
.get("/", async ({ request, headers }) => {
await requireSessionOrBearer(request, headers);
const allTasks = await db
.select()
.from(tasks)
@@ -35,7 +47,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
.post(
"/",
async ({ body, headers }) => {
requireAuth(headers);
requireBearerAuth(headers);
// Get max position for queued tasks
const maxPos = await db
.select({ max: sql<number>`COALESCE(MAX(${tasks.position}), 0)` })
@@ -93,7 +105,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
.patch(
"/:id",
async ({ params, body, headers }) => {
requireAuth(headers);
requireBearerAuth(headers);
const updates: Record<string, any> = { updatedAt: new Date() };
if (body.title !== undefined) updates.title = body.title;
if (body.description !== undefined) updates.description = body.description;
@@ -139,7 +151,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
.post(
"/:id/notes",
async ({ params, body, headers }) => {
requireAuth(headers);
requireBearerAuth(headers);
const existing = await db
.select()
.from(tasks)
@@ -170,7 +182,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
.patch(
"/reorder",
async ({ body, headers }) => {
requireAuth(headers);
requireBearerAuth(headers);
// body.ids is an ordered array of task IDs
const updates = body.ids.map((id: string, index: number) =>
db
@@ -190,7 +202,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
.delete(
"/:id",
async ({ params, headers }) => {
requireAuth(headers);
requireBearerAuth(headers);
const deleted = await db
.delete(tasks)
.where(eq(tasks.id, params.id))