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
This commit is contained in:
cheffe01
2026-05-25 14:11:02 +00:00
parent b8ba53598d
commit c5ca3ca992
44 changed files with 2554 additions and 34 deletions
+6
View File
@@ -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 (
<Switch>
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/tools" component={ToolsBrowse} />
<Route path="/tools/new" component={ToolNew} />
<Route path="/tools/:id/edit" component={ToolEdit} />
<Route path="/tools/:id" component={ToolDetail} />
<Route path="/analytics" component={Analytics} />
<Route path="/admin" component={Admin} />
<Route component={NotFound} />
</Switch>
);
+4 -3
View File
@@ -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"
>
<LogIn className="w-4 h-4" />
Sign in with Keycloak
{isLocalMode ? "Sign in" : "Sign in with Keycloak"}
</Button>
)}
</div>
+32 -13
View File
@@ -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,
};
}
+350
View File
@@ -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 (
<Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Admin Access Required</h2>
<p className="text-muted-foreground">You need admin rights to view this page.</p>
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
</div>
</Layout>
);
}
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 (
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
<p className="text-muted-foreground">Manage users and review system changes.</p>
</div>
<Tabs defaultValue="users">
<TabsList className="mb-4">
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" /> Users
</TabsTrigger>
<TabsTrigger value="audit" className="gap-2">
<ScrollText className="w-4 h-4" /> Audit Log
</TabsTrigger>
</TabsList>
<TabsContent value="users">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Local Users</CardTitle>
<CardDescription>Manage accounts for local authentication.</CardDescription>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" /> Add User
</Button>
</CardHeader>
<CardContent>
{loadingUsers ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="divide-y">
{users?.map((u) => (
<div key={u.id} className="flex items-center justify-between py-3 gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0 font-medium text-primary text-sm">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{u.username}</p>
{u.email && <p className="text-xs text-muted-foreground truncate">{u.email}</p>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
{u.role}
</Badge>
{u.username !== user?.preferredUsername && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role })}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteConfirm({ id: u.id, username: u.username })}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</div>
))}
{(!users || users.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No users yet.</p>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="audit">
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
</CardHeader>
<CardContent>
{loadingLogs ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
) : (
<div className="divide-y">
{auditLogs?.map((log) => (
<div key={log.id} className="py-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={actionBadgeVariant(log.action)} className="capitalize text-xs">
{log.action}
</Badge>
<span className="text-sm font-medium capitalize">{log.entityType}</span>
{log.entityId && (
<span className="text-sm text-muted-foreground">#{log.entityId}</span>
)}
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{format(new Date(log.createdAt), "dd.MM.yyyy HH:mm")}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
{log.changes && (
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
</span>
)}
</div>
</div>
))}
{(!auditLogs || auditLogs.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No audit entries yet.</p>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Username</Label>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
</div>
<div className="space-y-2">
<Label>Email (Optional)</Label>
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
{createUser.isPending ? "Creating…" : "Create User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Change Role {editUser?.username}</DialogTitle>
</DialogHeader>
<div className="py-2">
<Select
value={editUser?.role || "user"}
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
<Button
onClick={() => handleUpdateRole(editUser?.role as "admin" | "user")}
disabled={updateUser.isPending}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground py-2">
Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm?.username}</span>? This cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
{deleteUser.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}
+3 -1
View File
@@ -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 })) || [];
+102
View File
@@ -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<string | null>(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 (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex items-center gap-2 text-primary font-bold text-2xl">
<Wrench className="w-7 h-7" />
<span>ToolRate</span>
</div>
<p className="text-muted-foreground text-sm">Sign in to your account</p>
</div>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">Sign in</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<div className="flex items-center gap-2 text-destructive text-sm rounded-md bg-destructive/10 px-3 py-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={localLogin.isPending}
>
{localLogin.isPending ? "Signing in…" : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}
+84 -3
View File
@@ -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 (
<>
<Layout>
<div className="space-y-6 max-w-5xl mx-auto pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
@@ -183,7 +224,7 @@ export default function ToolDetail() {
<div className="text-sm text-muted-foreground">
Based on {tool.ratingCount} reviews
</div>
{tool.websiteUrl && (
<Button asChild className="w-full mt-2" variant="outline">
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
@@ -191,6 +232,24 @@ export default function ToolDetail() {
</a>
</Button>
)}
{canEdit(tool) && (
<div className="flex gap-2 w-full mt-1">
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
<Link href={`/tools/${id}/edit`}>
<Pencil className="w-3.5 h-3.5" /> Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-3.5 h-3.5" /> Delete
</Button>
</div>
)}
</div>
</div>
@@ -423,5 +482,27 @@ export default function ToolDetail() {
)}
</div>
</Layout>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={deleteTool.isPending}
>
{deleteTool.isPending ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+348
View File
@@ -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<typeof toolSchema>;
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<ToolFormValues>({
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 (
<Layout>
<div className="max-w-3xl mx-auto space-y-6 pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href={`/tools/${id}`}>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to tool
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
<p className="text-muted-foreground">Update tool details and metadata.</p>
</div>
{isLoading ? (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
Tool Details
</CardTitle>
<CardDescription>Modify the tool information below.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tool name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Website URL (Optional)</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{field.value ? (
<img
src={field.value}
alt="icon preview"
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xs text-muted-foreground">img</span>
)}
</div>
<Input
placeholder="https://example.com/logo.png"
type="url"
{...field}
className="flex-1"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do?"
className="min-h-[120px] resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Features</h3>
<p className="text-sm text-muted-foreground">Key capabilities of this tool.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
<Plus className="w-4 h-4 mr-2" /> Add Feature
</Button>
</div>
<div className="space-y-3">
{featureFields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`features.${index}.value`}
render={({ field }) => (
<FormItem className="flex items-start gap-2 space-y-0">
<FormControl>
<FeatureInput
value={field.value ?? ""}
onChange={field.onChange}
placeholder="e.g. Real-time collaboration"
/>
</FormControl>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFeature(index)}
>
<X className="w-4 h-4" />
</Button>
</FormItem>
)}
/>
))}
{featureFields.length === 0 && (
<p className="text-sm text-muted-foreground italic">No features added.</p>
)}
</div>
</div>
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Tags</h3>
<p className="text-sm text-muted-foreground">Keywords for this tool.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
<Plus className="w-4 h-4 mr-2" /> Add Tag
</Button>
</div>
<div className="flex flex-wrap gap-2">
{tagFields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`tags.${index}.value`}
render={({ field }) => (
<FormItem className="flex items-center space-y-0 relative w-[150px]">
<FormControl>
<Input placeholder="Tag" className="pr-8 h-9 text-sm" {...field} />
</FormControl>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeTag(index)}
>
<X className="w-3 h-3" />
</Button>
</FormItem>
)}
/>
))}
</div>
</div>
<div className="pt-6 border-t flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/tools/${id}`}>Cancel</Link>
</Button>
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
{updateTool.isPending ? "Saving…" : "Save Changes"}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)}
</div>
</Layout>
);
}
+1 -1
View File
@@ -249,7 +249,7 @@ export default function ToolNew() {
<FormItem className="flex items-start gap-2 space-y-0">
<FormControl>
<FeatureInput
value={field.value}
value={field.value ?? ""}
onChange={field.onChange}
placeholder="e.g. Real-time collaboration"
data-testid={`input-feature-${index}`}