Add user authentication and dynamic feature/category inputs

Implement Keycloak authentication, protected routes, and add combobox and autocomplete components for tool categories and features.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 0b145113-c016-4f54-b000-13bd3b0ba8f0
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/z4uWN6A
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
cheffe01
2026-05-25 13:20:15 +00:00
parent e03c51a75e
commit 7abb048edc
22 changed files with 1027 additions and 68 deletions
+5
View File
@@ -12,17 +12,22 @@
"dependencies": {
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"drizzle-orm": "catalog:",
"express": "^5.2.1",
"express-session": "^1.19.0",
"openid-client": "^5.7.1",
"pino": "^9.14.0",
"pino-http": "^10.5.0"
},
"devDependencies": {
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "catalog:",
"esbuild": "0.27.3",
"esbuild-plugin-pino": "^2.3.3",
+28 -1
View File
@@ -1,11 +1,18 @@
import express, { type Express } from "express";
import cors from "cors";
import pinoHttp from "pino-http";
import session from "express-session";
import ConnectPgSimple from "connect-pg-simple";
import router from "./routes";
import { logger } from "./lib/logger";
import "./types/session.d.ts";
const PgStore = ConnectPgSimple(session);
const app: Express = express();
app.set("trust proxy", 1);
app.use(
pinoHttp({
logger,
@@ -25,10 +32,30 @@ app.use(
},
}),
);
app.use(cors());
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(
session({
store: new PgStore({
conString: process.env.DATABASE_URL,
tableName: "sessions",
createTableIfMissing: true,
}),
secret: process.env.SESSION_SECRET || "dev-secret-change-in-production",
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
},
}),
);
app.use("/api", router);
export default app;
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,9 @@
import { type Request, type Response, type NextFunction } from "express";
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (!req.session.user) {
res.status(401).json({ error: "Authentication required" });
return;
}
next();
}
+138
View File
@@ -0,0 +1,138 @@
import { Router, type IRouter, type Request } from "express";
import { Issuer, generators, type Client } from "openid-client";
import { logger } from "../lib/logger";
const router: IRouter = Router();
let cachedClient: Client | null = null;
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";
const proto = req.get("x-forwarded-proto") || "https";
return `${proto}://${host}`;
}
async function getClient(): Promise<Client | null> {
if (cachedClient) return cachedClient;
const keycloakUrl = process.env.KEYCLOAK_URL;
const realm = process.env.KEYCLOAK_REALM;
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
if (!keycloakUrl || !realm || !clientId || !clientSecret) {
return null;
}
try {
const issuerUrl = `${keycloakUrl}/realms/${realm}`;
const issuer = await Issuer.discover(issuerUrl);
cachedClient = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
response_types: ["code"],
});
return cachedClient;
} catch (err) {
logger.error({ err }, "Failed to discover Keycloak issuer");
return null;
}
}
router.get("/auth/login", async (req, res): Promise<void> => {
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." });
return;
}
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
req.session.codeVerifier = codeVerifier;
if (req.query.returnTo && typeof req.query.returnTo === "string") {
req.session.returnTo = req.query.returnTo;
}
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
const url = client.authorizationUrl({
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
redirect_uri: redirectUri,
});
res.redirect(url);
});
router.get("/auth/callback", async (req, res): Promise<void> => {
const client = await getClient();
if (!client) {
res.status(503).json({ error: "Keycloak is not configured." });
return;
}
const codeVerifier = req.session.codeVerifier;
if (!codeVerifier) {
res.status(400).json({ error: "Invalid session state." });
return;
}
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
try {
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, {
code_verifier: codeVerifier,
});
const userinfo = await client.userinfo(tokenSet.access_token!);
req.session.user = {
sub: userinfo.sub,
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,
};
delete req.session.codeVerifier;
const returnTo = req.session.returnTo || "/";
delete req.session.returnTo;
res.redirect(returnTo);
} catch (err) {
logger.error({ err }, "Keycloak callback failed");
res.status(500).json({ error: "Authentication failed." });
}
});
router.get("/auth/logout", async (req, res): Promise<void> => {
const user = req.session.user;
req.session.destroy(() => {});
const client = await getClient();
if (client && client.issuer.metadata.end_session_endpoint) {
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
res.redirect(logoutUrl);
return;
}
res.redirect("/");
});
router.get("/auth/me", async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const u = req.session.user;
res.json({
sub: u.sub,
email: u.email ?? null,
name: u.name ?? null,
preferredUsername: u.preferred_username ?? null,
});
});
export default router;
+2
View File
@@ -3,9 +3,11 @@ import healthRouter from "./health";
import toolsRouter from "./tools";
import ratingsRouter from "./ratings";
import analyticsRouter from "./analytics";
import authRouter from "./auth";
const router: IRouter = Router();
router.use(authRouter);
router.use(healthRouter);
router.use(toolsRouter);
router.use(ratingsRouter);
+2 -1
View File
@@ -6,6 +6,7 @@ import {
CreateRatingParams,
CreateRatingBody,
} from "@workspace/api-zod";
import { requireAuth } from "../middleware/auth";
const router: IRouter = Router();
@@ -31,7 +32,7 @@ router.get("/tools/:id/ratings", async (req, res): Promise<void> => {
res.json(ratings);
});
router.post("/tools/:id/ratings", async (req, res): Promise<void> => {
router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise<void> => {
const params = CreateRatingParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
+16 -4
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express";
import { eq, ilike, desc, sql, avg, count } from "drizzle-orm";
import { eq, ilike, desc, sql } from "drizzle-orm";
import { db, toolsTable, ratingsTable } from "@workspace/db";
import {
ListToolsQueryParams,
@@ -9,6 +9,7 @@ import {
UpdateToolBody,
DeleteToolParams,
} from "@workspace/api-zod";
import { requireAuth } from "../middleware/auth";
const router: IRouter = Router();
@@ -71,7 +72,7 @@ router.get("/tools", async (req, res): Promise<void> => {
res.json(result);
});
router.post("/tools", async (req, res): Promise<void> => {
router.post("/tools", requireAuth, async (req, res): Promise<void> => {
const parsed = CreateToolBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
@@ -111,7 +112,7 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
res.json(buildToolWithStats(tool, ratings));
});
router.patch("/tools/:id", async (req, res): Promise<void> => {
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
const params = UpdateToolParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
@@ -146,7 +147,7 @@ router.patch("/tools/:id", async (req, res): Promise<void> => {
res.json(tool);
});
router.delete("/tools/:id", async (req, res): Promise<void> => {
router.delete("/tools/:id", requireAuth, async (req, res): Promise<void> => {
const params = DeleteToolParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
@@ -170,4 +171,15 @@ router.get("/categories", async (_req, res): Promise<void> => {
res.json(rows.map((r) => r.category));
});
router.get("/features/all", async (_req, res): Promise<void> => {
const tools = await db.select({ features: toolsTable.features }).from(toolsTable);
const featureSet = new Set<string>();
for (const t of tools) {
for (const f of t.features ?? []) {
if (f && f.trim()) featureSet.add(f.trim());
}
}
res.json([...featureSet].sort());
});
export default router;
+14
View File
@@ -0,0 +1,14 @@
import "express-session";
declare module "express-session" {
interface SessionData {
user?: {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
};
codeVerifier?: string;
returnTo?: string;
}
}
@@ -0,0 +1,112 @@
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useListCategories } from "@workspace/api-client-react";
interface CategoryComboboxProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const categories = useListCategories();
const known: string[] = categories.data ?? [];
const filtered = inputValue.trim()
? known.filter((c) => c.toLowerCase().includes(inputValue.toLowerCase()))
: known;
const showCreateOption = inputValue.trim() !== "" && !known.some(
(c) => c.toLowerCase() === inputValue.toLowerCase()
);
function select(val: string) {
onChange(val);
setInputValue(val);
setOpen(false);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9"
data-testid="button-category-combobox"
>
<span className={cn(!value && "text-muted-foreground")}>
{value || placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or enter new category..."
value={inputValue}
onValueChange={(v) => {
setInputValue(v);
onChange(v);
}}
data-testid="input-category-search"
/>
<CommandList>
{filtered.length === 0 && !showCreateOption && (
<CommandEmpty>No categories found.</CommandEmpty>
)}
{filtered.length > 0 && (
<CommandGroup heading="Known categories">
{filtered.map((cat) => (
<CommandItem
key={cat}
value={cat}
onSelect={() => select(cat)}
data-testid={`item-category-${cat}`}
>
<Check
className={cn("mr-2 h-4 w-4", value === cat ? "opacity-100" : "opacity-0")}
/>
{cat}
</CommandItem>
))}
</CommandGroup>
)}
{showCreateOption && (
<CommandGroup heading="Create new">
<CommandItem
value={inputValue}
onSelect={() => select(inputValue.trim())}
data-testid="item-category-create-new"
>
<span className="text-primary font-medium">+ Create</span>
<span className="ml-2 text-muted-foreground">&ldquo;{inputValue.trim()}&rdquo;</span>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,74 @@
import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { useListAllFeatures } from "@workspace/api-client-react";
import { cn } from "@/lib/utils";
interface FeatureInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
"data-testid"?: string;
}
export function FeatureInput({ value, onChange, placeholder, "data-testid": testId }: FeatureInputProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const allFeatures = useListAllFeatures();
const known: string[] = allFeatures.data ?? [];
const suggestions = value.trim().length >= 1
? known.filter(
(f) =>
f.toLowerCase().includes(value.toLowerCase()) &&
f.toLowerCase() !== value.toLowerCase()
).slice(0, 6)
: [];
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={containerRef} className="relative w-full">
<Input
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder={placeholder}
data-testid={testId}
autoComplete="off"
/>
{open && suggestions.length > 0 && (
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-md text-sm overflow-hidden">
{suggestions.map((s) => (
<button
key={s}
type="button"
className={cn(
"w-full text-left px-3 py-2 hover:bg-muted transition-colors text-foreground",
)}
onMouseDown={(e) => {
e.preventDefault();
onChange(s);
setOpen(false);
}}
data-testid={`suggestion-feature-${s}`}
>
{s}
</button>
))}
</div>
)}
</div>
);
}
+70 -3
View File
@@ -1,8 +1,12 @@
import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, PlusCircle, BarChart3 } from "lucide-react";
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User } 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 links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
@@ -25,20 +29,83 @@ export function Layout({ children }: { children: React.ReactNode }) {
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
const Icon = link.icon;
return (
<Link key={link.href} href={link.href} className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${isActive ? "bg-primary text-primary-foreground font-medium" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}>
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${isActive ? "bg-primary text-primary-foreground font-medium" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
>
<Icon className="w-5 h-5" />
{link.label}
</Link>
);
})}
</nav>
<div className="p-4 border-t">
{isLoading ? (
<div className="flex items-center gap-3 px-3 py-2">
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
) : isAuthenticated && user ? (
<div className="space-y-2">
<div className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50">
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate text-foreground">
{user.name || user.preferredUsername || "User"}
</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-destructive"
onClick={logout}
data-testid="button-logout"
>
<LogOut className="w-4 h-4" />
Sign out
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full gap-2"
onClick={() => login(location)}
data-testid="button-login"
>
<LogIn className="w-4 h-4" />
Sign in with Keycloak
</Button>
)}
</div>
</aside>
<main className="flex-1 flex flex-col min-w-0">
<header className="md:hidden border-b p-4 flex items-center bg-card">
<header className="md:hidden border-b p-4 flex items-center justify-between bg-card">
<div className="flex items-center gap-2 text-primary font-bold text-lg">
<Wrench className="w-5 h-5" />
<span>ToolRate</span>
</div>
{!isLoading && (
isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
<LogOut className="w-4 h-4" />
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
<LogIn className="w-4 h-4 mr-1" />
Sign in
</Button>
)
)}
</header>
<div className="flex-1 p-6 md:p-8 overflow-auto">
{children}
+32
View File
@@ -0,0 +1,32 @@
import { useGetMe } from "@workspace/api-client-react";
export type AuthUser = {
sub: string;
email?: string | null;
name?: string | null;
preferredUsername?: string | null;
};
export function useAuth() {
const { data: user, isLoading, error } = useGetMe({
query: {
retry: false,
staleTime: 1000 * 60 * 5,
},
});
const isAuthenticated = !!user && !error;
function login(returnTo?: string) {
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 };
}
+85 -50
View File
@@ -1,4 +1,3 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -10,11 +9,14 @@ 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, FormDescription } from "@/components/ui/form";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { Wrench, Plus, X, ArrowLeft } from "lucide-react";
import { Wrench, Plus, X, ArrowLeft, LogIn } 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"),
@@ -28,10 +30,11 @@ const toolSchema = z.object({
type ToolFormValues = z.infer<typeof toolSchema>;
export default function ToolNew() {
const [, setLocation] = useLocation();
const [location, setLocation] = useLocation();
const { toast } = useToast();
const queryClient = useQueryClient();
const createTool = useCreateTool();
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
const form = useForm<ToolFormValues>({
resolver: zodResolver(toolSchema),
@@ -41,7 +44,7 @@ export default function ToolNew() {
category: "",
websiteUrl: "",
features: [{ value: "" }],
tags: [{ value: "" }]
tags: [{ value: "" }],
},
});
@@ -56,38 +59,35 @@ export default function ToolNew() {
});
const onSubmit = (data: ToolFormValues) => {
// Transform arrays back to strings
const payload = {
...data,
websiteUrl: data.websiteUrl || undefined,
features: data.features?.map(f => f.value).filter(v => v.trim() !== ""),
tags: data.tags?.map(t => t.value).filter(v => v.trim() !== "")
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""),
};
createTool.mutate({ data: payload }, {
onSuccess: (newTool) => {
toast({
title: "Tool added successfully",
description: "Your tool is now available for review.",
});
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
setLocation(`/tools/${newTool.id}`);
createTool.mutate(
{ data: payload },
{
onSuccess: (newTool) => {
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
setLocation(`/tools/${newTool.id}`);
},
onError: () => {
toast({ title: "Failed to add tool", description: "An unexpected error occurred.", variant: "destructive" });
},
},
onError: (error) => {
toast({
title: "Failed to add tool",
description: error.error || "An unexpected error occurred",
variant: "destructive"
});
}
});
);
};
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"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
<Link href="/tools">
<ArrowLeft className="w-4 h-4 mr-2" /> Back to browse
</Link>
</Button>
<div>
@@ -95,6 +95,19 @@ export default function ToolNew() {
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
</div>
{!authLoading && !isAuthenticated && (
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
<LogIn className="w-5 h-5 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Sign in required</p>
<p className="text-xs text-muted-foreground">You must be signed in to submit a tool.</p>
</div>
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
Sign in
</Button>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -106,7 +119,6 @@ export default function ToolNew() {
<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}
@@ -115,7 +127,7 @@ export default function ToolNew() {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g. React, Next.js, Postgres" {...field} />
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
</FormControl>
<FormMessage />
</FormItem>
@@ -129,7 +141,10 @@ export default function ToolNew() {
<FormItem>
<FormLabel>Category</FormLabel>
<FormControl>
<Input placeholder="e.g. Framework, Database, CI/CD" {...field} />
<CategoryCombobox
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -144,7 +159,7 @@ export default function ToolNew() {
<FormItem>
<FormLabel>Website URL (Optional)</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} />
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
</FormControl>
<FormMessage />
</FormItem>
@@ -158,10 +173,11 @@ export default function ToolNew() {
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do? Why do people use it?"
<Textarea
placeholder="What does this tool do? Why do people use it?"
className="min-h-[120px] resize-none"
{...field}
{...field}
data-testid="input-tool-description"
/>
</FormControl>
<FormMessage />
@@ -173,18 +189,19 @@ export default function ToolNew() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Features</h3>
<p className="text-sm text-muted-foreground">List key capabilities of the tool.</p>
<p className="text-sm text-muted-foreground">List key capabilities. Start typing to see suggestions from existing tools.</p>
</div>
<Button
type="button"
variant="outline"
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendFeature({ value: "" })}
data-testid="button-add-feature"
>
<Plus className="w-4 h-4 mr-2" /> Add Feature
</Button>
</div>
<div className="space-y-3">
{featureFields.map((field, index) => (
<FormField
@@ -194,14 +211,20 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem className="flex items-start gap-2 space-y-0">
<FormControl>
<Input placeholder="e.g. Real-time collaboration" {...field} />
<FeatureInput
value={field.value}
onChange={field.onChange}
placeholder="e.g. Real-time collaboration"
data-testid={`input-feature-${index}`}
/>
</FormControl>
<Button
type="button"
variant="ghost"
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFeature(index)}
data-testid={`button-remove-feature-${index}`}
>
<X className="w-4 h-4" />
</Button>
@@ -221,16 +244,17 @@ export default function ToolNew() {
<h3 className="text-lg font-medium">Tags</h3>
<p className="text-sm text-muted-foreground">Keywords to help find this tool.</p>
</div>
<Button
type="button"
variant="outline"
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendTag({ value: "" })}
data-testid="button-add-tag"
>
<Plus className="w-4 h-4 mr-2" /> Add Tag
</Button>
</div>
<div className="flex flex-wrap gap-2">
{tagFields.map((field, index) => (
<FormField
@@ -240,14 +264,20 @@ export default function ToolNew() {
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} />
<Input
placeholder="Tag"
className="pr-8 h-9 text-sm"
{...field}
data-testid={`input-tag-${index}`}
/>
</FormControl>
<Button
type="button"
variant="ghost"
<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)}
data-testid={`button-remove-tag-${index}`}
>
<X className="w-3 h-3" />
</Button>
@@ -259,7 +289,12 @@ export default function ToolNew() {
</div>
<div className="pt-6 border-t flex justify-end">
<Button type="submit" disabled={createTool.isPending} className="w-full sm:w-auto">
<Button
type="submit"
disabled={createTool.isPending || (!authLoading && !isAuthenticated)}
className="w-full sm:w-auto"
data-testid="button-submit-tool"
>
{createTool.isPending ? "Adding Tool..." : "Submit Tool"}
</Button>
</div>