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:
@@ -64,6 +64,7 @@ export const users = pgTable("users", {
|
|||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: boolean("email_verified").notNull().default(false),
|
emailVerified: boolean("email_verified").notNull().default(false),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
role: text("role").notNull().default("user"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import { taskRoutes } from "./routes/tasks";
|
import { taskRoutes } from "./routes/tasks";
|
||||||
|
import { adminRoutes } from "./routes/admin";
|
||||||
import { auth } from "./lib/auth";
|
import { auth } from "./lib/auth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { tasks } from "./db/schema";
|
import { tasks, users } from "./db/schema";
|
||||||
import { isNull, asc, sql } from "drizzle-orm";
|
import { isNull, asc, sql, eq } from "drizzle-orm";
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3100;
|
const PORT = process.env.PORT || 3100;
|
||||||
|
|
||||||
@@ -34,6 +35,20 @@ async function backfillTaskNumbers() {
|
|||||||
|
|
||||||
backfillTaskNumbers().catch(console.error);
|
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()
|
const app = new Elysia()
|
||||||
.use(
|
.use(
|
||||||
cors({
|
cors({
|
||||||
@@ -98,6 +113,27 @@ const app = new Elysia()
|
|||||||
})
|
})
|
||||||
|
|
||||||
.use(taskRoutes)
|
.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" }))
|
.get("/health", () => ({ status: "ok", service: "hammer-queue" }))
|
||||||
.onError(({ error, set }) => {
|
.onError(({ error, set }) => {
|
||||||
const msg = error?.message || String(error);
|
const msg = error?.message || String(error);
|
||||||
|
|||||||
89
backend/src/routes/admin.ts
Normal file
89
backend/src/routes/admin.ts
Normal 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() }),
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -68,6 +68,19 @@ async function requireSessionOrBearer(request: Request, headers: Record<string,
|
|||||||
throw new Error("Unauthorized");
|
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")
|
// Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5")
|
||||||
async function resolveTask(idOrNumber: string) {
|
async function resolveTask(idOrNumber: string) {
|
||||||
// Strip "HQ-" prefix if present
|
// Strip "HQ-" prefix if present
|
||||||
|
|||||||
@@ -1,34 +1,26 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useTasks } from "./hooks/useTasks";
|
import { useTasks } from "./hooks/useTasks";
|
||||||
|
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||||
import { TaskCard } from "./components/TaskCard";
|
import { TaskCard } from "./components/TaskCard";
|
||||||
import { TaskDetailPanel } from "./components/TaskDetailPanel";
|
import { TaskDetailPanel } from "./components/TaskDetailPanel";
|
||||||
import { CreateTaskModal } from "./components/CreateTaskModal";
|
import { CreateTaskModal } from "./components/CreateTaskModal";
|
||||||
|
import { AdminPage } from "./components/AdminPage";
|
||||||
import { LoginPage } from "./components/LoginPage";
|
import { LoginPage } from "./components/LoginPage";
|
||||||
import { useSession, signOut } from "./lib/auth-client";
|
import { useSession, signOut } from "./lib/auth-client";
|
||||||
import { updateTask, reorderTasks, createTask } from "./lib/api";
|
import { updateTask, reorderTasks, createTask } from "./lib/api";
|
||||||
import type { Task, TaskStatus } from "./lib/types";
|
import type { TaskStatus } from "./lib/types";
|
||||||
|
|
||||||
// Token stored in localStorage for bearer-token admin operations
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("hammer-queue-token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { tasks, loading, error, refresh } = useTasks(5000);
|
const { tasks, loading, error, refresh } = useTasks(5000);
|
||||||
|
const { user, isAdmin, isAuthenticated } = useCurrentUser();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
||||||
const [tokenInput, setTokenInput] = useState("");
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
const [showTokenInput, setShowTokenInput] = useState(false);
|
|
||||||
const session = useSession();
|
|
||||||
|
|
||||||
const token = getToken();
|
|
||||||
const hasToken = !!token;
|
|
||||||
|
|
||||||
// Keep selected task in sync with refreshed data
|
|
||||||
const selectedTaskData = useMemo(() => {
|
const selectedTaskData = useMemo(() => {
|
||||||
if (!selectedTask) return null;
|
if (!selectedTask) return null;
|
||||||
return tasks.find((t) => t.id === selectedTask.id) || null;
|
return tasks.find((t) => t.id === selectedTask) || null;
|
||||||
}, [tasks, selectedTask]);
|
}, [tasks, selectedTask]);
|
||||||
|
|
||||||
const activeTasks = useMemo(() => tasks.filter((t) => t.status === "active"), [tasks]);
|
const activeTasks = useMemo(() => tasks.filter((t) => t.status === "active"), [tasks]);
|
||||||
@@ -40,31 +32,27 @@ function Dashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleStatusChange = async (id: string, status: TaskStatus) => {
|
const handleStatusChange = async (id: string, status: TaskStatus) => {
|
||||||
if (!hasToken) {
|
|
||||||
setShowTokenInput(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await updateTask(id, { status }, token);
|
await updateTask(id, { status });
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Failed to update task. Check your token.");
|
alert("Failed to update task.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveUp = async (index: number) => {
|
const handleMoveUp = async (index: number) => {
|
||||||
if (index === 0 || !hasToken) return;
|
if (index === 0) return;
|
||||||
const ids = queuedTasks.map((t) => t.id);
|
const ids = queuedTasks.map((t) => t.id);
|
||||||
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||||
await reorderTasks(ids, token);
|
await reorderTasks(ids);
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveDown = async (index: number) => {
|
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);
|
const ids = queuedTasks.map((t) => t.id);
|
||||||
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||||
await reorderTasks(ids, token);
|
await reorderTasks(ids);
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,25 +62,19 @@ function Dashboard() {
|
|||||||
source?: string;
|
source?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!hasToken) {
|
await createTask(task);
|
||||||
setShowTokenInput(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await createTask(task, token);
|
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetToken = () => {
|
|
||||||
localStorage.setItem("hammer-queue-token", tokenInput);
|
|
||||||
setTokenInput("");
|
|
||||||
setShowTokenInput(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showAdmin) {
|
||||||
|
return <AdminPage onBack={() => setShowAdmin(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -104,25 +86,28 @@ function Dashboard() {
|
|||||||
<span className="text-xs text-gray-400 mt-1">Task Dashboard</span>
|
<span className="text-xs text-gray-400 mt-1">Task Dashboard</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{hasToken && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="text-sm bg-amber-500 text-white px-3 py-1.5 rounded-lg hover:bg-amber-600 transition font-medium"
|
className="text-sm bg-amber-500 text-white px-3 py-1.5 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||||
>
|
>
|
||||||
+ New Task
|
+ New Task
|
||||||
</button>
|
</button>
|
||||||
)}
|
{isAdmin && (
|
||||||
{!hasToken && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTokenInput(true)}
|
onClick={() => setShowAdmin(true)}
|
||||||
className="text-xs text-gray-400 hover:text-gray-600 px-2 py-1 border border-gray-200 rounded-lg"
|
className="text-xs text-purple-600 hover:text-purple-800 px-2 py-1 border border-purple-200 rounded-lg transition"
|
||||||
title="Set API token for admin actions"
|
title="Admin panel"
|
||||||
>
|
>
|
||||||
🔑 Admin
|
⚙️ Admin
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<span className="hidden sm:inline">{session.data?.user?.email}</span>
|
<span className="hidden sm:inline">{user?.email}</span>
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full font-medium">
|
||||||
|
admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1 border border-gray-200 rounded-lg transition"
|
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1 border border-gray-200 rounded-lg transition"
|
||||||
@@ -135,41 +120,6 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Token input modal */}
|
|
||||||
{showTokenInput && (
|
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4">
|
|
||||||
<h2 className="text-lg font-bold mb-2">API Token</h2>
|
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
|
||||||
Enter the bearer token for admin actions (create, update, delete tasks).
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Bearer token..."
|
|
||||||
value={tokenInput}
|
|
||||||
onChange={(e) => setTokenInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSetToken()}
|
|
||||||
className="w-full border rounded-lg px-3 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSetToken}
|
|
||||||
className="flex-1 bg-amber-500 text-white rounded-lg py-2 text-sm font-medium hover:bg-amber-600"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTokenInput(false)}
|
|
||||||
className="px-4 py-2 text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateTaskModal
|
<CreateTaskModal
|
||||||
open={showCreate}
|
open={showCreate}
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
@@ -203,7 +153,7 @@ function Dashboard() {
|
|||||||
task={task}
|
task={task}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
isActive
|
isActive
|
||||||
onClick={() => setSelectedTask(task)}
|
onClick={() => setSelectedTask(task.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +172,7 @@ function Dashboard() {
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onClick={() => setSelectedTask(task)}
|
onClick={() => setSelectedTask(task.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +199,7 @@ function Dashboard() {
|
|||||||
onMoveDown={() => handleMoveDown(i)}
|
onMoveDown={() => handleMoveDown(i)}
|
||||||
isFirst={i === 0}
|
isFirst={i === 0}
|
||||||
isLast={i === queuedTasks.length - 1}
|
isLast={i === queuedTasks.length - 1}
|
||||||
onClick={() => setSelectedTask(task)}
|
onClick={() => setSelectedTask(task.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,7 +221,7 @@ function Dashboard() {
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onClick={() => setSelectedTask(task)}
|
onClick={() => setSelectedTask(task.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -289,14 +239,14 @@ function Dashboard() {
|
|||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
}}
|
}}
|
||||||
onTaskUpdated={refresh}
|
onTaskUpdated={refresh}
|
||||||
hasToken={hasToken}
|
hasToken={isAuthenticated}
|
||||||
token={token}
|
token=""
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="text-center text-xs text-gray-300 py-4">
|
<footer className="text-center text-xs text-gray-300 py-4">
|
||||||
Hammer Queue v0.1 · Auto-refreshes every 5s
|
Hammer Queue v0.2 · Auto-refreshes every 5s
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
125
frontend/src/components/AdminPage.tsx
Normal file
125
frontend/src/components/AdminPage.tsx
Normal file
@@ -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<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Admin</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Manage users and roles</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50 font-medium text-gray-600"
|
||||||
|
>
|
||||||
|
← Back to Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||||
|
<h2 className="font-semibold text-gray-700">Users ({users.length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-400">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.id} className="px-6 py-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900">{user.name}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
user.role === "admin"
|
||||||
|
? "bg-purple-100 text-purple-700"
|
||||||
|
: "bg-gray-100 text-gray-600"
|
||||||
|
}`}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{user.email}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<select
|
||||||
|
value={user.role}
|
||||||
|
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||||
|
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 bg-white hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.id, user.name)}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 transition"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/hooks/useCurrentUser.ts
Normal file
51
frontend/src/hooks/useCurrentUser.ts
Normal file
@@ -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<CurrentUser | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,29 +11,27 @@ export async function fetchTasks(): Promise<Task[]> {
|
|||||||
export async function updateTask(
|
export async function updateTask(
|
||||||
id: string,
|
id: string,
|
||||||
updates: Record<string, any>,
|
updates: Record<string, any>,
|
||||||
token: string
|
token?: string
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
const res = await fetch(`${BASE}/${id}`, {
|
const res = await fetch(`${BASE}/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to update task");
|
if (!res.ok) throw new Error("Failed to update task");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderTasks(ids: string[], token: string): Promise<void> {
|
export async function reorderTasks(ids: string[], token?: string): Promise<void> {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
const res = await fetch(`${BASE}/reorder`, {
|
const res = await fetch(`${BASE}/reorder`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to reorder tasks");
|
if (!res.ok) throw new Error("Failed to reorder tasks");
|
||||||
@@ -41,26 +39,53 @@ export async function reorderTasks(ids: string[], token: string): Promise<void>
|
|||||||
|
|
||||||
export async function createTask(
|
export async function createTask(
|
||||||
task: { title: string; description?: string; source?: string; priority?: string; status?: string },
|
task: { title: string; description?: string; source?: string; priority?: string; status?: string },
|
||||||
token: string
|
token?: string
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
const res = await fetch(BASE, {
|
const res = await fetch(BASE, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(task),
|
body: JSON.stringify(task),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to create task");
|
if (!res.ok) throw new Error("Failed to create task");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTask(id: string, token: string): Promise<void> {
|
export async function deleteTask(id: string, token?: string): Promise<void> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
const res = await fetch(`${BASE}/${id}`, {
|
const res = await fetch(`${BASE}/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to delete task");
|
if (!res.ok) throw new Error("Failed to delete task");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
export async function fetchUsers(): Promise<any[]> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {
|
||||||
|
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete user");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user