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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user