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 + +