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:
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
32
backend/src/lib/auth.ts
Normal 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,
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user