From 93746f0f71cbcbca5ca375e9c16ae478fdbd3b53 Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 01:33:18 +0000 Subject: [PATCH] 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 --- backend/src/db/schema.ts | 1 + backend/src/index.ts | 40 +++++++- backend/src/routes/admin.ts | 89 +++++++++++++++++ backend/src/routes/tasks.ts | 13 +++ frontend/src/App.tsx | 134 ++++++++------------------ frontend/src/components/AdminPage.tsx | 125 ++++++++++++++++++++++++ frontend/src/hooks/useCurrentUser.ts | 51 ++++++++++ frontend/src/lib/api.ts | 59 ++++++++---- 8 files changed, 401 insertions(+), 111 deletions(-) create mode 100644 backend/src/routes/admin.ts create mode 100644 frontend/src/components/AdminPage.tsx create mode 100644 frontend/src/hooks/useCurrentUser.ts diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 913ba7f..6d38543 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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(), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index a6ff7b3..4949b53 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..7c67bd9 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -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) { + 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() }), + } + ); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index f2adfec..7e8600c 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -68,6 +68,19 @@ async function requireSessionOrBearer(request: Request, headers: Record) { + // 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a7665e8..b6aa7c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,34 +1,26 @@ import { useState, useMemo } from "react"; import { useTasks } from "./hooks/useTasks"; +import { useCurrentUser } from "./hooks/useCurrentUser"; import { TaskCard } from "./components/TaskCard"; import { TaskDetailPanel } from "./components/TaskDetailPanel"; import { CreateTaskModal } from "./components/CreateTaskModal"; +import { AdminPage } from "./components/AdminPage"; import { LoginPage } from "./components/LoginPage"; import { useSession, signOut } from "./lib/auth-client"; import { updateTask, reorderTasks, createTask } from "./lib/api"; -import type { Task, TaskStatus } from "./lib/types"; - -// Token stored in localStorage for bearer-token admin operations -function getToken(): string { - return localStorage.getItem("hammer-queue-token") || ""; -} +import type { TaskStatus } from "./lib/types"; function Dashboard() { const { tasks, loading, error, refresh } = useTasks(5000); + const { user, isAdmin, isAuthenticated } = useCurrentUser(); const [showCreate, setShowCreate] = useState(false); const [showCompleted, setShowCompleted] = useState(false); - const [selectedTask, setSelectedTask] = useState(null); - const [tokenInput, setTokenInput] = useState(""); - const [showTokenInput, setShowTokenInput] = useState(false); - const session = useSession(); + const [selectedTask, setSelectedTask] = useState(null); + const [showAdmin, setShowAdmin] = useState(false); - const token = getToken(); - const hasToken = !!token; - - // Keep selected task in sync with refreshed data const selectedTaskData = useMemo(() => { if (!selectedTask) return null; - return tasks.find((t) => t.id === selectedTask.id) || null; + return tasks.find((t) => t.id === selectedTask) || null; }, [tasks, selectedTask]); const activeTasks = useMemo(() => tasks.filter((t) => t.status === "active"), [tasks]); @@ -40,31 +32,27 @@ function Dashboard() { ); const handleStatusChange = async (id: string, status: TaskStatus) => { - if (!hasToken) { - setShowTokenInput(true); - return; - } try { - await updateTask(id, { status }, token); + await updateTask(id, { status }); refresh(); } catch (e) { - alert("Failed to update task. Check your token."); + alert("Failed to update task."); } }; const handleMoveUp = async (index: number) => { - if (index === 0 || !hasToken) return; + if (index === 0) return; const ids = queuedTasks.map((t) => t.id); [ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]; - await reorderTasks(ids, token); + await reorderTasks(ids); refresh(); }; const handleMoveDown = async (index: number) => { - if (index >= queuedTasks.length - 1 || !hasToken) return; + if (index >= queuedTasks.length - 1) return; const ids = queuedTasks.map((t) => t.id); [ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]; - await reorderTasks(ids, token); + await reorderTasks(ids); refresh(); }; @@ -74,25 +62,19 @@ function Dashboard() { source?: string; priority?: string; }) => { - if (!hasToken) { - setShowTokenInput(true); - return; - } - await createTask(task, token); + await createTask(task); refresh(); }; - const handleSetToken = () => { - localStorage.setItem("hammer-queue-token", tokenInput); - setTokenInput(""); - setShowTokenInput(false); - }; - const handleLogout = async () => { await signOut(); window.location.reload(); }; + if (showAdmin) { + return setShowAdmin(false)} />; + } + return (
{/* Header */} @@ -104,25 +86,28 @@ function Dashboard() { Task Dashboard
- {hasToken && ( + + {isAdmin && ( - )} - {!hasToken && ( - )}
- {session.data?.user?.email} + {user?.email} + {isAdmin && ( + + admin + + )} - -
-
- - )} - setShowCreate(false)} @@ -203,7 +153,7 @@ function Dashboard() { task={task} onStatusChange={handleStatusChange} isActive - onClick={() => setSelectedTask(task)} + onClick={() => setSelectedTask(task.id)} /> ))} @@ -222,7 +172,7 @@ function Dashboard() { key={task.id} task={task} onStatusChange={handleStatusChange} - onClick={() => setSelectedTask(task)} + onClick={() => setSelectedTask(task.id)} /> ))} @@ -249,7 +199,7 @@ function Dashboard() { onMoveDown={() => handleMoveDown(i)} isFirst={i === 0} isLast={i === queuedTasks.length - 1} - onClick={() => setSelectedTask(task)} + onClick={() => setSelectedTask(task.id)} /> ))} @@ -271,7 +221,7 @@ function Dashboard() { key={task.id} task={task} onStatusChange={handleStatusChange} - onClick={() => setSelectedTask(task)} + onClick={() => setSelectedTask(task.id)} /> ))} @@ -289,14 +239,14 @@ function Dashboard() { setSelectedTask(null); }} onTaskUpdated={refresh} - hasToken={hasToken} - token={token} + hasToken={isAuthenticated} + token="" /> )} {/* Footer */}
- Hammer Queue v0.1 ยท Auto-refreshes every 5s + Hammer Queue v0.2 ยท Auto-refreshes every 5s
); diff --git a/frontend/src/components/AdminPage.tsx b/frontend/src/components/AdminPage.tsx new file mode 100644 index 0000000..78ed05b --- /dev/null +++ b/frontend/src/components/AdminPage.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from "react"; +import { fetchUsers, updateUserRole, deleteUser } from "../lib/api"; + +interface User { + id: string; + name: string; + email: string; + role: string; + createdAt: string; +} + +export function AdminPage({ onBack }: { onBack: () => void }) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadUsers = async () => { + try { + setLoading(true); + const data = await fetchUsers(); + setUsers(data); + setError(null); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadUsers(); }, []); + + const handleRoleChange = async (userId: string, newRole: string) => { + try { + await updateUserRole(userId, newRole); + loadUsers(); + } catch (e: any) { + alert(`Failed to update role: ${e.message}`); + } + }; + + const handleDelete = async (userId: string, userName: string) => { + if (!confirm(`Delete user "${userName}"? This cannot be undone.`)) return; + try { + await deleteUser(userId); + loadUsers(); + } catch (e: any) { + alert(`Failed to delete user: ${e.message}`); + } + }; + + return ( +
+
+
+

Admin

+

Manage users and roles

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+

Users ({users.length})

+
+ + {loading ? ( +
Loading...
+ ) : ( +
+ {users.map((user) => ( +
+
+
+ {user.name} + + {user.role} + +
+

{user.email}

+

+ Joined {new Date(user.createdAt).toLocaleDateString()} +

+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useCurrentUser.ts b/frontend/src/hooks/useCurrentUser.ts new file mode 100644 index 0000000..4798dc3 --- /dev/null +++ b/frontend/src/hooks/useCurrentUser.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; + +interface CurrentUser { + id: string; + name: string; + email: string; + role: string; +} + +interface UseCurrentUserResult { + user: CurrentUser | null; + loading: boolean; + isAdmin: boolean; + isAuthenticated: boolean; + refresh: () => void; +} + +export function useCurrentUser(): UseCurrentUserResult { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchUser = async () => { + try { + const res = await fetch("/api/me", { credentials: "include" }); + if (!res.ok) { + setUser(null); + return; + } + const data = await res.json(); + if (data.authenticated) { + setUser(data.user); + } else { + setUser(null); + } + } catch { + setUser(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchUser(); }, []); + + return { + user, + loading, + isAdmin: user?.role === "admin", + isAuthenticated: !!user, + refresh: fetchUser, + }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7409f9b..d0d736b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,29 +11,27 @@ export async function fetchTasks(): Promise { export async function updateTask( id: string, updates: Record, - token: string + token?: string ): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(`${BASE}/${id}`, { method: "PATCH", credentials: "include", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify(updates), }); if (!res.ok) throw new Error("Failed to update task"); return res.json(); } -export async function reorderTasks(ids: string[], token: string): Promise { +export async function reorderTasks(ids: string[], token?: string): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(`${BASE}/reorder`, { method: "PATCH", credentials: "include", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify({ ids }), }); if (!res.ok) throw new Error("Failed to reorder tasks"); @@ -41,26 +39,53 @@ export async function reorderTasks(ids: string[], token: string): Promise export async function createTask( task: { title: string; description?: string; source?: string; priority?: string; status?: string }, - token: string + token?: string ): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(BASE, { method: "POST", credentials: "include", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify(task), }); if (!res.ok) throw new Error("Failed to create task"); return res.json(); } -export async function deleteTask(id: string, token: string): Promise { +export async function deleteTask(id: string, token?: string): Promise { + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(`${BASE}/${id}`, { method: "DELETE", credentials: "include", - headers: { Authorization: `Bearer ${token}` }, + headers, }); if (!res.ok) throw new Error("Failed to delete task"); } + +// Admin API +export async function fetchUsers(): Promise { + const res = await fetch("/api/admin/users", { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch users"); + return res.json(); +} + +export async function updateUserRole(userId: string, role: string): Promise { + const res = await fetch(`/api/admin/users/${userId}/role`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }); + if (!res.ok) throw new Error("Failed to update user role"); + return res.json(); +} + +export async function deleteUser(userId: string): Promise { + const res = await fetch(`/api/admin/users/${userId}`, { + method: "DELETE", + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to delete user"); +}