diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 6916f27..d123cff 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -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", diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index f32f71e..1fa50eb 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -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; diff --git a/artifacts/api-server/src/hooks/use-auth.ts b/artifacts/api-server/src/hooks/use-auth.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/artifacts/api-server/src/hooks/use-auth.ts @@ -0,0 +1 @@ +export {}; diff --git a/artifacts/api-server/src/middleware/auth.ts b/artifacts/api-server/src/middleware/auth.ts new file mode 100644 index 0000000..3e88449 --- /dev/null +++ b/artifacts/api-server/src/middleware/auth.ts @@ -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(); +} diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts new file mode 100644 index 0000000..7f01b2b --- /dev/null +++ b/artifacts/api-server/src/routes/auth.ts @@ -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 { + 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 => { + 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 => { + 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 => { + 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 => { + 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; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 7b6c72b..634fa48 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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); diff --git a/artifacts/api-server/src/routes/ratings.ts b/artifacts/api-server/src/routes/ratings.ts index 66dfc55..6ef348a 100644 --- a/artifacts/api-server/src/routes/ratings.ts +++ b/artifacts/api-server/src/routes/ratings.ts @@ -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 => { res.json(ratings); }); -router.post("/tools/:id/ratings", async (req, res): Promise => { +router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise => { const params = CreateRatingParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index 21466dd..a75d7b1 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -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 => { res.json(result); }); -router.post("/tools", async (req, res): Promise => { +router.post("/tools", requireAuth, async (req, res): Promise => { 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 => { res.json(buildToolWithStats(tool, ratings)); }); -router.patch("/tools/:id", async (req, res): Promise => { +router.patch("/tools/:id", requireAuth, async (req, res): Promise => { 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 => { res.json(tool); }); -router.delete("/tools/:id", async (req, res): Promise => { +router.delete("/tools/:id", requireAuth, async (req, res): Promise => { 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 => { res.json(rows.map((r) => r.category)); }); +router.get("/features/all", async (_req, res): Promise => { + const tools = await db.select({ features: toolsTable.features }).from(toolsTable); + const featureSet = new Set(); + 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; diff --git a/artifacts/api-server/src/types/session.d.ts b/artifacts/api-server/src/types/session.d.ts new file mode 100644 index 0000000..91fca0c --- /dev/null +++ b/artifacts/api-server/src/types/session.d.ts @@ -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; + } +} diff --git a/artifacts/toolrate/src/components/category-combobox.tsx b/artifacts/toolrate/src/components/category-combobox.tsx new file mode 100644 index 0000000..5c46cfd --- /dev/null +++ b/artifacts/toolrate/src/components/category-combobox.tsx @@ -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 ( + + + + + + + { + setInputValue(v); + onChange(v); + }} + data-testid="input-category-search" + /> + + {filtered.length === 0 && !showCreateOption && ( + No categories found. + )} + {filtered.length > 0 && ( + + {filtered.map((cat) => ( + select(cat)} + data-testid={`item-category-${cat}`} + > + + {cat} + + ))} + + )} + {showCreateOption && ( + + select(inputValue.trim())} + data-testid="item-category-create-new" + > + + Create + “{inputValue.trim()}” + + + )} + + + + + ); +} diff --git a/artifacts/toolrate/src/components/feature-input.tsx b/artifacts/toolrate/src/components/feature-input.tsx new file mode 100644 index 0000000..9897db6 --- /dev/null +++ b/artifacts/toolrate/src/components/feature-input.tsx @@ -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(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 ( +
+ { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + placeholder={placeholder} + data-testid={testId} + autoComplete="off" + /> + {open && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+ ); +} diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index d999c5d..29d6e01 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -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.label} ); })} + +
+ {isLoading ? ( +
+ + +
+ ) : isAuthenticated && user ? ( +
+
+
+ +
+
+

+ {user.name || user.preferredUsername || "User"} +

+ {user.email && ( +

{user.email}

+ )} +
+
+ +
+ ) : ( + + )} +
+
-
+
ToolRate
+ {!isLoading && ( + isAuthenticated ? ( + + ) : ( + + ) + )}
{children} diff --git a/artifacts/toolrate/src/hooks/use-auth.ts b/artifacts/toolrate/src/hooks/use-auth.ts new file mode 100644 index 0000000..b3b0392 --- /dev/null +++ b/artifacts/toolrate/src/hooks/use-auth.ts @@ -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 }; +} diff --git a/artifacts/toolrate/src/pages/tool-new.tsx b/artifacts/toolrate/src/pages/tool-new.tsx index e318718..95ee38c 100644 --- a/artifacts/toolrate/src/pages/tool-new.tsx +++ b/artifacts/toolrate/src/pages/tool-new.tsx @@ -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; 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({ 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 (
@@ -95,6 +95,19 @@ export default function ToolNew() {

Submit a tool you use to let the community rate and review it.

+ {!authLoading && !isAuthenticated && ( +
+ +
+

Sign in required

+

You must be signed in to submit a tool.

+
+ +
+ )} + @@ -106,7 +119,6 @@ export default function ToolNew() {
-
Name - + @@ -129,7 +141,10 @@ export default function ToolNew() { Category - + @@ -144,7 +159,7 @@ export default function ToolNew() { Website URL (Optional) - + @@ -158,10 +173,11 @@ export default function ToolNew() { Description -