From c5ca3ca99246fef8f1947c8ee768bae55b7f540b Mon Sep 17 00:00:00 2001 From: cheffe01 <32869042-cheffe01@users.noreply.replit.com> Date: Mon, 25 May 2026 14:11:02 +0000 Subject: [PATCH] Add local user authentication and admin capabilities Implement local user authentication with password hashing, add admin roles for user management and audit log viewing, and introduce audit logging for critical actions. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 832a44ff-12ae-4096-8a0d-666ec083d536 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/1p7jhzu Replit-Helium-Checkpoint-Created: true --- artifacts/api-server/package.json | 2 + artifacts/api-server/src/index.ts | 28 + artifacts/api-server/src/lib/audit.ts | 22 + artifacts/api-server/src/middleware/auth.ts | 12 + artifacts/api-server/src/routes/audit.ts | 33 ++ artifacts/api-server/src/routes/auth.ts | 71 ++- artifacts/api-server/src/routes/index.ts | 4 + artifacts/api-server/src/routes/tools.ts | 76 ++- artifacts/api-server/src/routes/users.ts | 131 +++++ artifacts/api-server/src/types/session.d.ts | 2 + artifacts/toolrate/src/App.tsx | 6 + artifacts/toolrate/src/components/layout.tsx | 7 +- artifacts/toolrate/src/hooks/use-auth.ts | 45 +- artifacts/toolrate/src/pages/admin.tsx | 350 ++++++++++++ artifacts/toolrate/src/pages/analytics.tsx | 4 +- artifacts/toolrate/src/pages/login.tsx | 102 ++++ artifacts/toolrate/src/pages/tool-detail.tsx | 87 ++- artifacts/toolrate/src/pages/tool-edit.tsx | 348 ++++++++++++ artifacts/toolrate/src/pages/tool-new.tsx | 2 +- .../src/generated/api.schemas.ts | 96 ++++ lib/api-client-react/src/generated/api.ts | 531 +++++++++++++++++- lib/api-spec/openapi.yaml | 264 +++++++++ lib/api-zod/src/generated/api.ts | 115 +++- lib/api-zod/src/generated/types/auditLog.ts | 20 + lib/api-zod/src/generated/types/authMode.ts | 12 + .../src/generated/types/authModeMode.ts | 15 + lib/api-zod/src/generated/types/authUser.ts | 3 + .../src/generated/types/authUserRole.ts | 15 + lib/api-zod/src/generated/types/index.ts | 12 + .../generated/types/listAuditLogsParams.ts | 13 + .../src/generated/types/localLoginInput.ts | 12 + lib/api-zod/src/generated/types/tool.ts | 2 + .../src/generated/types/toolWithStats.ts | 2 + lib/api-zod/src/generated/types/user.ts | 17 + .../src/generated/types/userCreateInput.ts | 17 + .../generated/types/userCreateInputRole.ts | 15 + lib/api-zod/src/generated/types/userRole.ts | 15 + .../src/generated/types/userRoleUpdate.ts | 12 + .../src/generated/types/userRoleUpdateRole.ts | 15 + lib/db/src/schema/audit-logs.ts | 14 + lib/db/src/schema/index.ts | 2 + lib/db/src/schema/tools.ts | 1 + lib/db/src/schema/users.ts | 16 + pnpm-lock.yaml | 20 + 44 files changed, 2554 insertions(+), 34 deletions(-) create mode 100644 artifacts/api-server/src/lib/audit.ts create mode 100644 artifacts/api-server/src/routes/audit.ts create mode 100644 artifacts/api-server/src/routes/users.ts create mode 100644 artifacts/toolrate/src/pages/admin.tsx create mode 100644 artifacts/toolrate/src/pages/login.tsx create mode 100644 artifacts/toolrate/src/pages/tool-edit.tsx create mode 100644 lib/api-zod/src/generated/types/auditLog.ts create mode 100644 lib/api-zod/src/generated/types/authMode.ts create mode 100644 lib/api-zod/src/generated/types/authModeMode.ts create mode 100644 lib/api-zod/src/generated/types/authUserRole.ts create mode 100644 lib/api-zod/src/generated/types/listAuditLogsParams.ts create mode 100644 lib/api-zod/src/generated/types/localLoginInput.ts create mode 100644 lib/api-zod/src/generated/types/user.ts create mode 100644 lib/api-zod/src/generated/types/userCreateInput.ts create mode 100644 lib/api-zod/src/generated/types/userCreateInputRole.ts create mode 100644 lib/api-zod/src/generated/types/userRole.ts create mode 100644 lib/api-zod/src/generated/types/userRoleUpdate.ts create mode 100644 lib/api-zod/src/generated/types/userRoleUpdateRole.ts create mode 100644 lib/db/src/schema/audit-logs.ts create mode 100644 lib/db/src/schema/users.ts diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index d123cff..b67417b 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -12,6 +12,7 @@ "dependencies": { "@workspace/api-zod": "workspace:*", "@workspace/db": "workspace:*", + "bcryptjs": "^3.0.3", "connect-pg-simple": "^10.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.6", @@ -23,6 +24,7 @@ "pino-http": "^10.5.0" }, "devDependencies": { + "@types/bcryptjs": "^3.0.0", "@types/connect-pg-simple": "^7.0.3", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index b1f024d..251ed56 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -1,5 +1,8 @@ import app from "./app"; import { logger } from "./lib/logger"; +import bcrypt from "bcryptjs"; +import { db, usersTable } from "@workspace/db"; +import { sql } from "drizzle-orm"; const rawPort = process.env["PORT"]; @@ -15,6 +18,30 @@ if (Number.isNaN(port) || port <= 0) { throw new Error(`Invalid PORT value: "${rawPort}"`); } +async function seedAdminUser(): Promise { + try { + const [row] = await db.select({ count: sql`count(*)::int` }).from(usersTable); + if ((row?.count ?? 0) > 0) return; + + const adminUsername = process.env.LOCAL_ADMIN_USERNAME || "admin"; + const adminPassword = process.env.LOCAL_ADMIN_PASSWORD; + if (!adminPassword) { + logger.warn("LOCAL_ADMIN_PASSWORD is not set — skipping admin seed. Set it to enable local login."); + return; + } + + const passwordHash = await bcrypt.hash(adminPassword, 10); + await db.insert(usersTable).values({ + username: adminUsername, + passwordHash, + role: "admin", + }); + logger.info({ username: adminUsername }, "Admin user created"); + } catch (err) { + logger.error({ err }, "Failed to seed admin user"); + } +} + app.listen(port, (err) => { if (err) { logger.error({ err }, "Error listening on port"); @@ -22,4 +49,5 @@ app.listen(port, (err) => { } logger.info({ port }, "Server listening"); + seedAdminUser(); }); diff --git a/artifacts/api-server/src/lib/audit.ts b/artifacts/api-server/src/lib/audit.ts new file mode 100644 index 0000000..a227ad4 --- /dev/null +++ b/artifacts/api-server/src/lib/audit.ts @@ -0,0 +1,22 @@ +import { db, auditLogsTable } from "@workspace/db"; +import { type Request } from "express"; + +export async function writeAuditLog( + req: Request, + entityType: string, + entityId: number | null, + action: string, + changes?: Record, +): Promise { + const user = req.session.user; + if (!user) return; + const username = user.preferred_username || user.name || user.sub; + await db.insert(auditLogsTable).values({ + entityType, + entityId, + action, + userId: user.sub, + username, + changes: changes ? JSON.stringify(changes) : null, + }); +} diff --git a/artifacts/api-server/src/middleware/auth.ts b/artifacts/api-server/src/middleware/auth.ts index 3e88449..df8e69c 100644 --- a/artifacts/api-server/src/middleware/auth.ts +++ b/artifacts/api-server/src/middleware/auth.ts @@ -7,3 +7,15 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo } next(); } + +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + if (!req.session.user) { + res.status(401).json({ error: "Authentication required" }); + return; + } + if (req.session.user.role !== "admin") { + res.status(403).json({ error: "Admin access required" }); + return; + } + next(); +} diff --git a/artifacts/api-server/src/routes/audit.ts b/artifacts/api-server/src/routes/audit.ts new file mode 100644 index 0000000..6791f41 --- /dev/null +++ b/artifacts/api-server/src/routes/audit.ts @@ -0,0 +1,33 @@ +import { Router, type IRouter } from "express"; +import { eq, desc, and } from "drizzle-orm"; +import { db, auditLogsTable } from "@workspace/db"; +import { requireAdmin } from "../middleware/auth"; + +const router: IRouter = Router(); + +router.get("/audit-logs", requireAdmin, async (req, res): Promise => { + const { entityType, entityId, limit } = req.query; + const maxLimit = Math.min(parseInt(limit as string) || 100, 500); + + const conditions: ReturnType[] = []; + if (entityType && typeof entityType === "string") { + conditions.push(eq(auditLogsTable.entityType, entityType)); + } + if (entityId) { + const id = parseInt(entityId as string, 10); + if (!isNaN(id)) { + conditions.push(eq(auditLogsTable.entityId, id)); + } + } + + const logs = await db + .select() + .from(auditLogsTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(auditLogsTable.createdAt)) + .limit(maxLimit); + + res.json(logs); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 7f01b2b..e05d50c 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -1,11 +1,23 @@ import { Router, type IRouter, type Request } from "express"; import { Issuer, generators, type Client } from "openid-client"; +import bcrypt from "bcryptjs"; +import { eq } from "drizzle-orm"; +import { db, usersTable } from "@workspace/db"; import { logger } from "../lib/logger"; const router: IRouter = Router(); let cachedClient: Client | null = null; +function isOidcConfigured(): boolean { + return !!( + process.env.KEYCLOAK_URL && + process.env.KEYCLOAK_REALM && + process.env.KEYCLOAK_CLIENT_ID && + process.env.KEYCLOAK_CLIENT_SECRET + ); +} + function getBaseUrl(req: Request): string { if (process.env.APP_URL) return process.env.APP_URL; const host = req.get("x-forwarded-host") || req.get("host") || "localhost"; @@ -40,10 +52,62 @@ async function getClient(): Promise { } } +router.get("/auth/mode", (_req, res): void => { + res.json({ mode: isOidcConfigured() ? "oidc" : "local" }); +}); + +router.post("/auth/login", async (req, res): Promise => { + if (isOidcConfigured()) { + res.status(400).json({ error: "Use OIDC login when Keycloak is configured." }); + return; + } + + const { username, password } = req.body; + if (!username || !password) { + res.status(400).json({ error: "username and password are required" }); + return; + } + + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.username, String(username))) + .limit(1); + + if (!user) { + res.status(401).json({ error: "Invalid username or password" }); + return; + } + + const valid = await bcrypt.compare(String(password), user.passwordHash); + if (!valid) { + res.status(401).json({ error: "Invalid username or password" }); + return; + } + + req.session.user = { + sub: String(user.id), + name: user.username, + preferred_username: user.username, + email: user.email ?? undefined, + role: (user.role as "admin" | "user") ?? "user", + isLocal: true, + }; + + res.json({ + sub: String(user.id), + email: user.email ?? null, + name: user.username, + preferredUsername: user.username, + role: user.role, + isLocal: true, + }); +}); + router.get("/auth/login", async (req, res): Promise => { const client = await getClient(); if (!client) { - res.status(503).json({ error: "Keycloak is not configured. Set KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET." }); + res.status(503).json({ error: "Keycloak is not configured." }); return; } @@ -94,6 +158,8 @@ router.get("/auth/callback", async (req, res): Promise => { email: typeof userinfo.email === "string" ? userinfo.email : undefined, name: typeof userinfo.name === "string" ? userinfo.name : undefined, preferred_username: typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined, + role: "user", + isLocal: false, }; delete req.session.codeVerifier; @@ -108,7 +174,6 @@ router.get("/auth/callback", async (req, res): Promise => { }); router.get("/auth/logout", async (req, res): Promise => { - const user = req.session.user; req.session.destroy(() => {}); const client = await getClient(); @@ -132,6 +197,8 @@ router.get("/auth/me", async (req, res): Promise => { email: u.email ?? null, name: u.name ?? null, preferredUsername: u.preferred_username ?? null, + role: u.role ?? "user", + isLocal: u.isLocal ?? false, }); }); diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 634fa48..0a43be0 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -4,6 +4,8 @@ import toolsRouter from "./tools"; import ratingsRouter from "./ratings"; import analyticsRouter from "./analytics"; import authRouter from "./auth"; +import usersRouter from "./users"; +import auditRouter from "./audit"; const router: IRouter = Router(); @@ -12,5 +14,7 @@ router.use(healthRouter); router.use(toolsRouter); router.use(ratingsRouter); router.use(analyticsRouter); +router.use(usersRouter); +router.use(auditRouter); export default router; diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index 1b881da..bc45b2d 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -10,6 +10,7 @@ import { DeleteToolParams, } from "@workspace/api-zod"; import { requireAuth } from "../middleware/auth"; +import { writeAuditLog } from "../lib/audit"; const router: IRouter = Router(); @@ -27,6 +28,13 @@ function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { use return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined }; } +function canEditTool(req: import("express").Request, tool: { createdBy: string | null }): boolean { + const user = req.session.user; + if (!user) return false; + if (user.role === "admin") return true; + return tool.createdBy === user.sub || tool.createdBy === user.preferred_username; +} + router.get("/tools", async (req, res): Promise => { const parsed = ListToolsQueryParams.safeParse(req.query); if (!parsed.success) { @@ -79,16 +87,21 @@ router.post("/tools", requireAuth, async (req, res): Promise => { return; } + const user = req.session.user!; + const createdBy = user.preferred_username || user.name || user.sub; + const [tool] = await db.insert(toolsTable).values({ name: parsed.data.name, description: parsed.data.description, category: parsed.data.category, websiteUrl: parsed.data.websiteUrl ?? null, iconUrl: parsed.data.iconUrl ?? null, + createdBy, features: parsed.data.features ?? [], tags: parsed.data.tags ?? [], }).returning(); + await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category }); res.status(201).json(tool); }); @@ -120,6 +133,21 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise => { return; } + const [existing] = await db + .select() + .from(toolsTable) + .where(eq(toolsTable.id, params.data.id)); + + if (!existing) { + res.status(404).json({ error: "Tool not found" }); + return; + } + + if (!canEditTool(req, existing)) { + res.status(403).json({ error: "Not allowed to edit this tool" }); + return; + } + const parsed = UpdateToolBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); @@ -127,13 +155,28 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise => { } const updateData: Record = {}; - if (parsed.data.name !== undefined) updateData.name = parsed.data.name; - if (parsed.data.description !== undefined) updateData.description = parsed.data.description; - if (parsed.data.category !== undefined) updateData.category = parsed.data.category; - if (parsed.data.websiteUrl !== undefined) updateData.websiteUrl = parsed.data.websiteUrl; - if (parsed.data.iconUrl !== undefined) updateData.iconUrl = parsed.data.iconUrl; - if (parsed.data.features !== undefined) updateData.features = parsed.data.features; - if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags; + const changes: Record = {}; + + function track(key: keyof typeof existing, value: unknown) { + if (value !== undefined && value !== existing[key]) { + changes[key] = { from: existing[key], to: value }; + (updateData as Record)[key] = value; + } + } + + track("name", parsed.data.name); + track("description", parsed.data.description); + track("category", parsed.data.category); + track("websiteUrl", parsed.data.websiteUrl); + track("iconUrl", parsed.data.iconUrl); + if (parsed.data.features !== undefined) { + changes["features"] = { from: existing.features, to: parsed.data.features }; + updateData.features = parsed.data.features; + } + if (parsed.data.tags !== undefined) { + changes["tags"] = { from: existing.tags, to: parsed.data.tags }; + updateData.tags = parsed.data.tags; + } const [tool] = await db .update(toolsTable) @@ -146,6 +189,10 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise => { return; } + if (Object.keys(changes).length > 0) { + await writeAuditLog(req, "tool", tool.id, "update", changes); + } + res.json(tool); }); @@ -156,12 +203,23 @@ router.delete("/tools/:id", requireAuth, async (req, res): Promise => { return; } - const [tool] = await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id)).returning(); - if (!tool) { + const [existing] = await db + .select() + .from(toolsTable) + .where(eq(toolsTable.id, params.data.id)); + + if (!existing) { res.status(404).json({ error: "Tool not found" }); return; } + if (!canEditTool(req, existing)) { + res.status(403).json({ error: "Not allowed to delete this tool" }); + return; + } + + await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name }); + await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id)); res.sendStatus(204); }); diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts new file mode 100644 index 0000000..3d0f902 --- /dev/null +++ b/artifacts/api-server/src/routes/users.ts @@ -0,0 +1,131 @@ +import { Router, type IRouter } from "express"; +import { eq } from "drizzle-orm"; +import bcrypt from "bcryptjs"; +import { db, usersTable } from "@workspace/db"; +import { requireAdmin } from "../middleware/auth"; +import { writeAuditLog } from "../lib/audit"; +import { z } from "zod/v4"; + +const router: IRouter = Router(); + +const UserCreateSchema = z.object({ + username: z.string().min(2), + password: z.string().min(6), + email: z.string().optional(), + role: z.enum(["admin", "user"]).optional().default("user"), +}); + +const UserRoleUpdateSchema = z.object({ + role: z.enum(["admin", "user"]), +}); + +router.get("/users", requireAdmin, async (req, res): Promise => { + const users = await db + .select({ + id: usersTable.id, + username: usersTable.username, + email: usersTable.email, + role: usersTable.role, + createdAt: usersTable.createdAt, + }) + .from(usersTable) + .orderBy(usersTable.createdAt); + res.json(users); +}); + +router.post("/users", requireAdmin, async (req, res): Promise => { + const parsed = UserCreateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const existing = await db + .select({ id: usersTable.id }) + .from(usersTable) + .where(eq(usersTable.username, parsed.data.username)) + .limit(1); + + if (existing.length > 0) { + res.status(409).json({ error: "Username already exists" }); + return; + } + + const passwordHash = await bcrypt.hash(parsed.data.password, 10); + const [user] = await db + .insert(usersTable) + .values({ + username: parsed.data.username, + passwordHash, + email: parsed.data.email ?? null, + role: parsed.data.role ?? "user", + }) + .returning({ + id: usersTable.id, + username: usersTable.username, + email: usersTable.email, + role: usersTable.role, + createdAt: usersTable.createdAt, + }); + + await writeAuditLog(req, "user", user.id, "create", { username: user.username, role: user.role }); + res.status(201).json(user); +}); + +router.patch("/users/:id", requireAdmin, async (req, res): Promise => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + res.status(400).json({ error: "Invalid user id" }); + return; + } + + const parsed = UserRoleUpdateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [user] = await db + .update(usersTable) + .set({ role: parsed.data.role }) + .where(eq(usersTable.id, id)) + .returning({ + id: usersTable.id, + username: usersTable.username, + email: usersTable.email, + role: usersTable.role, + createdAt: usersTable.createdAt, + }); + + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + await writeAuditLog(req, "user", id, "update", { role: parsed.data.role }); + res.json(user); +}); + +router.delete("/users/:id", requireAdmin, async (req, res): Promise => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + res.status(400).json({ error: "Invalid user id" }); + return; + } + + if (req.session.user?.sub === String(id)) { + res.status(400).json({ error: "Cannot delete your own account" }); + return; + } + + const [user] = await db.delete(usersTable).where(eq(usersTable.id, id)).returning(); + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + await writeAuditLog(req, "user", id, "delete", { username: user.username }); + res.sendStatus(204); +}); + +export default router; diff --git a/artifacts/api-server/src/types/session.d.ts b/artifacts/api-server/src/types/session.d.ts index 91fca0c..b3954b9 100644 --- a/artifacts/api-server/src/types/session.d.ts +++ b/artifacts/api-server/src/types/session.d.ts @@ -7,6 +7,8 @@ declare module "express-session" { email?: string; name?: string; preferred_username?: string; + role?: "admin" | "user"; + isLocal?: boolean; }; codeVerifier?: string; returnTo?: string; diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index 6628fc5..a5b1259 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -8,7 +8,10 @@ import Home from "@/pages/home"; import ToolsBrowse from "@/pages/tools-browse"; import ToolDetail from "@/pages/tool-detail"; import ToolNew from "@/pages/tool-new"; +import ToolEdit from "@/pages/tool-edit"; import Analytics from "@/pages/analytics"; +import Admin from "@/pages/admin"; +import Login from "@/pages/login"; import NotFound from "@/pages/not-found"; const queryClient = new QueryClient({ @@ -24,10 +27,13 @@ function Router() { return ( + + + ); diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 29d6e01..cb23a50 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -1,18 +1,19 @@ import { Link, useLocation } from "wouter"; -import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User } from "lucide-react"; +import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; export function Layout({ children }: { children: React.ReactNode }) { const [location] = useLocation(); - const { user, isLoading, isAuthenticated, login, logout } = useAuth(); + const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, login, logout } = useAuth(); const links = [ { href: "/", label: "Dashboard", icon: LayoutDashboard }, { href: "/tools", label: "Browse Tools", icon: Wrench }, { href: "/tools/new", label: "Add Tool", icon: PlusCircle }, { href: "/analytics", label: "Analytics", icon: BarChart3 }, + ...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []), ]; return ( @@ -82,7 +83,7 @@ export function Layout({ children }: { children: React.ReactNode }) { data-testid="button-login" > - Sign in with Keycloak + {isLocalMode ? "Sign in" : "Sign in with Keycloak"} )} diff --git a/artifacts/toolrate/src/hooks/use-auth.ts b/artifacts/toolrate/src/hooks/use-auth.ts index b3b0392..cdf9f76 100644 --- a/artifacts/toolrate/src/hooks/use-auth.ts +++ b/artifacts/toolrate/src/hooks/use-auth.ts @@ -1,32 +1,51 @@ -import { useGetMe } from "@workspace/api-client-react"; - -export type AuthUser = { - sub: string; - email?: string | null; - name?: string | null; - preferredUsername?: string | null; -}; +import { useGetMe, useGetAuthMode, getGetMeQueryKey, getGetAuthModeQueryKey } from "@workspace/api-client-react"; export function useAuth() { const { data: user, isLoading, error } = useGetMe({ query: { + queryKey: getGetMeQueryKey(), retry: false, staleTime: 1000 * 60 * 5, }, }); + const { data: authMode } = useGetAuthMode({ + query: { + queryKey: getGetAuthModeQueryKey(), + staleTime: Infinity, + retry: false, + }, + }); + const isAuthenticated = !!user && !error; + const isAdmin = isAuthenticated && user?.role === "admin"; + const isLocalMode = authMode?.mode === "local"; function login(returnTo?: string) { - const url = returnTo - ? `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}` - : "/api/auth/login"; - window.location.href = url; + if (isLocalMode) { + const path = returnTo + ? `/login?returnTo=${encodeURIComponent(returnTo)}` + : "/login"; + window.location.href = path; + } else { + const url = returnTo + ? `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}` + : "/api/auth/login"; + window.location.href = url; + } } function logout() { window.location.href = "/api/auth/logout"; } - return { user: isAuthenticated ? user : null, isLoading, isAuthenticated, login, logout }; + return { + user: isAuthenticated ? user : null, + isLoading, + isAuthenticated, + isAdmin, + isLocalMode, + login, + logout, + }; } diff --git a/artifacts/toolrate/src/pages/admin.tsx b/artifacts/toolrate/src/pages/admin.tsx new file mode 100644 index 0000000..07f2a20 --- /dev/null +++ b/artifacts/toolrate/src/pages/admin.tsx @@ -0,0 +1,350 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { + useListUsers, + useCreateUser, + useUpdateUser, + useDeleteUser, + useListAuditLogs, + getListUsersQueryKey, + getListAuditLogsQueryKey, +} from "@workspace/api-client-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/hooks/use-auth"; +import { Layout } from "@/components/layout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useToast } from "@/hooks/use-toast"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock } from "lucide-react"; +import { format } from "date-fns"; + +export default function Admin() { + const [, setLocation] = useLocation(); + const { user, isAdmin, isLoading: authLoading } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const [createOpen, setCreateOpen] = useState(false); + const [editUser, setEditUser] = useState<{ id: number; username: string; role: string } | null>(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null); + + const [newUsername, setNewUsername] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newEmail, setNewEmail] = useState(""); + const [newRole, setNewRole] = useState<"admin" | "user">("user"); + + const { data: users, isLoading: loadingUsers } = useListUsers({ + query: { queryKey: getListUsersQueryKey(), enabled: isAdmin }, + }); + const { data: auditLogs, isLoading: loadingLogs } = useListAuditLogs( + { limit: 100 }, + { query: { queryKey: getListAuditLogsQueryKey({ limit: 100 }), enabled: isAdmin } }, + ); + + const createUser = useCreateUser(); + const updateUser = useUpdateUser(); + const deleteUser = useDeleteUser(); + + if (!authLoading && !isAdmin) { + return ( + +
+ +

Admin Access Required

+

You need admin rights to view this page.

+ +
+
+ ); + } + + const handleCreateUser = () => { + if (!newUsername || !newPassword) return; + createUser.mutate( + { data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole } }, + { + onSuccess: () => { + toast({ title: "User created", description: `${newUsername} has been created.` }); + queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }); + setCreateOpen(false); + setNewUsername(""); + setNewPassword(""); + setNewEmail(""); + setNewRole("user"); + }, + onError: (err) => { + toast({ title: "Failed to create user", description: err.data?.error ?? err.message, variant: "destructive" }); + }, + }, + ); + }; + + const handleUpdateRole = (role: "admin" | "user") => { + if (!editUser) return; + updateUser.mutate( + { id: editUser.id, data: { role } }, + { + onSuccess: () => { + toast({ title: "Role updated" }); + queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }); + setEditUser(null); + }, + onError: (err) => { + toast({ title: "Failed to update role", description: err.data?.error ?? err.message, variant: "destructive" }); + }, + }, + ); + }; + + const handleDeleteUser = () => { + if (!deleteConfirm) return; + deleteUser.mutate( + { id: deleteConfirm.id }, + { + onSuccess: () => { + toast({ title: "User deleted", description: `${deleteConfirm.username} has been removed.` }); + queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }); + setDeleteConfirm(null); + }, + onError: (err) => { + toast({ title: "Failed to delete user", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" }); + }, + }, + ); + }; + + function actionBadgeVariant(action: string): "default" | "secondary" | "destructive" | "outline" { + if (action === "create") return "default"; + if (action === "delete") return "destructive"; + return "secondary"; + } + + return ( + +
+
+

Admin Panel

+

Manage users and review system changes.

+
+ + + + + Users + + + Audit Log + + + + + + +
+ Local Users + Manage accounts for local authentication. +
+ +
+ + {loadingUsers ? ( +
+ {[1, 2, 3].map((i) => )} +
+ ) : ( +
+ {users?.map((u) => ( +
+
+
+ {u.username.charAt(0).toUpperCase()} +
+
+

{u.username}

+ {u.email &&

{u.email}

} +
+
+
+ + {u.role} + + {u.username !== user?.preferredUsername && ( + <> + + + + )} +
+
+ ))} + {(!users || users.length === 0) && ( +

No users yet.

+ )} +
+ )} +
+
+
+ + + + + Audit Log + All create, update and delete operations tracked by the system. + + + {loadingLogs ? ( +
+ {[1, 2, 3, 4, 5].map((i) => )} +
+ ) : ( +
+ {auditLogs?.map((log) => ( +
+
+ + {log.action} + + {log.entityType} + {log.entityId && ( + #{log.entityId} + )} + + + {format(new Date(log.createdAt), "dd.MM.yyyy HH:mm")} + +
+
+ by {log.username} + {log.changes && ( + + {log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes} + + )} +
+
+ ))} + {(!auditLogs || auditLogs.length === 0) && ( +

No audit entries yet.

+ )} +
+ )} +
+
+
+
+
+ + + + + Create New User + +
+
+ + setNewUsername(e.target.value)} placeholder="username" /> +
+
+ + setNewPassword(e.target.value)} placeholder="min. 6 characters" /> +
+
+ + setNewEmail(e.target.value)} placeholder="user@example.com" /> +
+
+ + +
+
+ + + + +
+
+ + !open && setEditUser(null)}> + + + Change Role — {editUser?.username} + +
+ +
+ + + + +
+
+ + !open && setDeleteConfirm(null)}> + + + Delete User + +

+ Are you sure you want to delete {deleteConfirm?.username}? This cannot be undone. +

+ + + + +
+
+
+ ); +} diff --git a/artifacts/toolrate/src/pages/analytics.tsx b/artifacts/toolrate/src/pages/analytics.tsx index b732f97..635e89d 100644 --- a/artifacts/toolrate/src/pages/analytics.tsx +++ b/artifacts/toolrate/src/pages/analytics.tsx @@ -36,7 +36,9 @@ export default function Analytics() { const categoryChartData = categoryStats?.map(c => ({ category: c.category, tools: c.toolCount, - avgScore: c.avgCombined ? Number(c.avgCombined.toFixed(2)) : 0 + avgScore: (c.avgUsefulness != null && c.avgUsability != null) + ? Number(((c.avgUsefulness + c.avgUsability) / 2).toFixed(2)) + : 0 })) || []; const usefulnessData = distribution?.usefulness.map(b => ({ score: `${b.score} Star`, count: b.count })) || []; diff --git a/artifacts/toolrate/src/pages/login.tsx b/artifacts/toolrate/src/pages/login.tsx new file mode 100644 index 0000000..65cddba --- /dev/null +++ b/artifacts/toolrate/src/pages/login.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { useLocation, useSearch } from "wouter"; +import { useLocalLogin } from "@workspace/api-client-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Wrench, AlertCircle } from "lucide-react"; + +export default function Login() { + const [, setLocation] = useLocation(); + const search = useSearch(); + const params = new URLSearchParams(search); + const returnTo = params.get("returnTo") || "/"; + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + const queryClient = useQueryClient(); + const localLogin = useLocalLogin(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + localLogin.mutate( + { data: { username, password } }, + { + onSuccess: () => { + queryClient.invalidateQueries(); + setLocation(returnTo); + }, + onError: (err) => { + setError(err.data?.error || err.message || "Invalid username or password"); + }, + }, + ); + }; + + return ( +
+
+
+
+ + ToolRate +
+

Sign in to your account

+
+ + + + Sign in + Enter your credentials to continue + + +
+
+ + setUsername(e.target.value)} + placeholder="admin" + autoFocus + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+
+
+
+ ); +} diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index 6ce453d..dcfbbb0 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -27,8 +27,20 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; -import { ExternalLink, Star, ArrowLeft, Plus } from "lucide-react"; +import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2 } from "lucide-react"; import { Link } from "wouter"; +import { useAuth } from "@/hooks/use-auth"; +import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; const ratingSchema = z.object({ usefulness: z.number().min(1).max(5), @@ -47,6 +59,10 @@ export default function ToolDetail() { const queryClient = useQueryClient(); const { toast } = useToast(); const [isReviewFormOpen, setIsReviewFormOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const { user, isAdmin } = useAuth(); + const deleteTool = useDeleteTool(); const { data: tool, isLoading: loadingTool } = useGetTool(id, { query: { enabled: !!id, queryKey: getGetToolQueryKey(id) } @@ -99,7 +115,7 @@ export default function ToolDetail() { onError: (error) => { toast({ title: "Failed to submit rating", - description: error.error || "An unexpected error occurred.", + description: error.data?.error || error.message || "An unexpected error occurred.", variant: "destructive" }); } @@ -122,7 +138,32 @@ export default function ToolDetail() { const usefulnessData = distribution?.usefulness.map(b => ({ score: b.score, count: b.count })).reverse() || []; const usabilityData = distribution?.usability.map(b => ({ score: b.score, count: b.count })).reverse() || []; + function canEdit(toolData: { createdBy?: string | null }): boolean { + if (!user) return false; + if (isAdmin) return true; + return toolData.createdBy === user.sub || + toolData.createdBy === user.preferredUsername; + } + + function handleDelete() { + deleteTool.mutate( + { id }, + { + onSuccess: () => { + toast({ title: "Tool deleted" }); + queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); + setLocation("/tools"); + }, + onError: (err) => { + toast({ title: "Failed to delete", description: err.data?.error || err.message, variant: "destructive" }); + setDeleteOpen(false); + }, + }, + ); + } + return ( + <>
)} + + {canEdit(tool) && ( +
+ + +
+ )}
@@ -423,5 +482,27 @@ export default function ToolDetail() { )}
+ + + + + Delete this tool? + + This will permanently remove {tool?.name} and all its ratings. This cannot be undone. + + + + Cancel + + {deleteTool.isPending ? "Deleting…" : "Delete"} + + + + + ); } diff --git a/artifacts/toolrate/src/pages/tool-edit.tsx b/artifacts/toolrate/src/pages/tool-edit.tsx new file mode 100644 index 0000000..2f48b6c --- /dev/null +++ b/artifacts/toolrate/src/pages/tool-edit.tsx @@ -0,0 +1,348 @@ +import { useEffect } from "react"; +import { useRoute, useLocation } from "wouter"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + useGetTool, + useUpdateTool, + getGetToolQueryKey, + getListToolsQueryKey, +} from "@workspace/api-client-react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { Layout } from "@/components/layout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useToast } from "@/hooks/use-toast"; +import { Pencil, Plus, X, ArrowLeft } from "lucide-react"; +import { Link } from "wouter"; +import { CategoryCombobox } from "@/components/category-combobox"; +import { FeatureInput } from "@/components/feature-input"; +import { useAuth } from "@/hooks/use-auth"; + +const toolSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters"), + description: z.string().min(10, "Description must be at least 10 characters"), + category: z.string().min(2, "Category is required"), + websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")), + iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")), + features: z.array(z.object({ value: z.string() })).optional(), + tags: z.array(z.object({ value: z.string() })).optional(), +}); + +type ToolFormValues = z.infer; + +export default function ToolEdit() { + const [match, params] = useRoute("/tools/:id/edit"); + const [, setLocation] = useLocation(); + const id = parseInt(params?.id || "0", 10); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const updateTool = useUpdateTool(); + const { isAuthenticated, isAdmin } = useAuth(); + + const { data: tool, isLoading } = useGetTool(id, { + query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }, + }); + + const form = useForm({ + resolver: zodResolver(toolSchema), + defaultValues: { + name: "", + description: "", + category: "", + websiteUrl: "", + iconUrl: "", + features: [], + tags: [], + }, + }); + + useEffect(() => { + if (tool) { + form.reset({ + name: tool.name, + description: tool.description, + category: tool.category, + websiteUrl: tool.websiteUrl || "", + iconUrl: tool.iconUrl || "", + features: (tool.features || []).map((v) => ({ value: v })), + tags: (tool.tags || []).map((v) => ({ value: v })), + }); + } + }, [tool, form]); + + const { fields: featureFields, append: appendFeature, remove: removeFeature } = useFieldArray({ + control: form.control, + name: "features", + }); + + const { fields: tagFields, append: appendTag, remove: removeTag } = useFieldArray({ + control: form.control, + name: "tags", + }); + + const onSubmit = (data: ToolFormValues) => { + const payload = { + ...data, + websiteUrl: data.websiteUrl || undefined, + iconUrl: data.iconUrl || undefined, + features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""), + tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""), + }; + + updateTool.mutate( + { id, data: payload }, + { + onSuccess: () => { + toast({ title: "Tool updated", description: "Changes saved successfully." }); + queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) }); + queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); + setLocation(`/tools/${id}`); + }, + onError: (err) => { + toast({ + title: "Failed to update tool", + description: err.data?.error || err.message || "An unexpected error occurred.", + variant: "destructive", + }); + }, + }, + ); + }; + + if (!match || isNaN(id)) { + return null; + } + + return ( + +
+ + +
+

Edit Tool

+

Update tool details and metadata.

+
+ + {isLoading ? ( + + + + + + + + ) : ( + + + + + Tool Details + + Modify the tool information below. + + +
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Category + + + + + + )} + /> +
+ + ( + + Website URL (Optional) + + + + + + )} + /> + + ( + + Icon / Logo URL (Optional) + +
+
+ {field.value ? ( + icon preview { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : ( + img + )} +
+ +
+
+ +
+ )} + /> + + ( + + Description + +