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

@@ -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))