feat: session-based auth, admin roles, user management

- All logged-in users can create/edit/manage tasks (no bearer token needed)
- Added user role system (user/admin)
- Donovan's account auto-promoted to admin on startup
- Admin page: view users, change roles, delete users
- /api/me endpoint returns current user info + role
- /api/admin/* routes (admin-only)
- Removed bearer token UI from frontend
- Bearer token still works for API/bot access
This commit is contained in:
2026-01-29 01:33:18 +00:00
parent 210fba6027
commit 93746f0f71
8 changed files with 401 additions and 111 deletions

View File

@@ -64,6 +64,7 @@ export const users = pgTable("users", {
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
role: text("role").notNull().default("user"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});

View File

@@ -1,10 +1,11 @@
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { taskRoutes } from "./routes/tasks";
import { adminRoutes } from "./routes/admin";
import { auth } from "./lib/auth";
import { db } from "./db";
import { tasks } from "./db/schema";
import { isNull, asc, sql } from "drizzle-orm";
import { tasks, users } from "./db/schema";
import { isNull, asc, sql, eq } from "drizzle-orm";
const PORT = process.env.PORT || 3100;
@@ -34,6 +35,20 @@ async function backfillTaskNumbers() {
backfillTaskNumbers().catch(console.error);
// Ensure donovan@donovankelly.xyz is admin
async function ensureAdmin() {
const adminEmail = "donovan@donovankelly.xyz";
const result = await db
.update(users)
.set({ role: "admin" })
.where(eq(users.email, adminEmail))
.returning({ id: users.id, email: users.email, role: users.role });
if (result.length) {
console.log(`Admin role ensured for ${adminEmail}`);
}
}
ensureAdmin().catch(console.error);
const app = new Elysia()
.use(
cors({
@@ -98,6 +113,27 @@ const app = new Elysia()
})
.use(taskRoutes)
.use(adminRoutes)
// Current user info (role, etc.)
.get("/api/me", async ({ request }) => {
try {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) return { authenticated: false };
return {
authenticated: true,
user: {
id: session.user.id,
name: session.user.name,
email: session.user.email,
role: (session.user as any).role || "user",
},
};
} catch {
return { authenticated: false };
}
})
.get("/health", () => ({ status: "ok", service: "hammer-queue" }))
.onError(({ error, set }) => {
const msg = error?.message || String(error);

View File

@@ -0,0 +1,89 @@
import { Elysia, t } from "elysia";
import { db } from "../db";
import { users } from "../db/schema";
import { eq } from "drizzle-orm";
import { auth } from "../lib/auth";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
async function requireAdmin(request: Request, headers: Record<string, string | undefined>) {
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user && (session.user as any).role === "admin") return;
} catch {}
throw new Error("Unauthorized");
}
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.onError(({ error, set }) => {
const msg = error?.message || String(error);
if (msg === "Unauthorized") {
set.status = 401;
return { error: "Unauthorized" };
}
console.error("Admin route error:", msg);
set.status = 500;
return { error: "Internal server error" };
})
// GET all users
.get("/users", async ({ request, headers }) => {
await requireAdmin(request, headers);
const allUsers = await db.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
createdAt: users.createdAt,
}).from(users);
return allUsers;
})
// PATCH update user role
.patch(
"/users/:id/role",
async ({ params, body, request, headers }) => {
await requireAdmin(request, headers);
const updated = await db
.update(users)
.set({ role: body.role })
.where(eq(users.id, params.id))
.returning({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
});
if (!updated.length) throw new Error("User not found");
return updated[0];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({ role: t.String() }),
}
)
// DELETE user
.delete(
"/users/:id",
async ({ params, request, headers }) => {
await requireAdmin(request, headers);
// Don't allow deleting yourself
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user?.id === params.id) {
throw new Error("Cannot delete yourself");
}
const deleted = await db
.delete(users)
.where(eq(users.id, params.id))
.returning();
if (!deleted.length) throw new Error("User not found");
return { success: true };
},
{
params: t.Object({ id: t.String() }),
}
);

View File

@@ -68,6 +68,19 @@ async function requireSessionOrBearer(request: Request, headers: Record<string,
throw new Error("Unauthorized");
}
async function requireAdmin(request: Request, headers: Record<string, string | undefined>) {
// Bearer token = admin access
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
// Check session + role
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user && (session.user as any).role === "admin") return;
} catch {}
throw new Error("Unauthorized");
}
// Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5")
async function resolveTask(idOrNumber: string) {
// Strip "HQ-" prefix if present