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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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">“{inputValue.trim()}”</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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }, {
|
||||
createTool.mutate(
|
||||
{ data: payload },
|
||||
{
|
||||
onSuccess: (newTool) => {
|
||||
toast({
|
||||
title: "Tool added successfully",
|
||||
description: "Your tool is now available for review.",
|
||||
});
|
||||
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to add tool",
|
||||
description: error.error || "An unexpected error occurred",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
onError: () => {
|
||||
toast({ title: "Failed to add tool", description: "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>
|
||||
@@ -162,6 +177,7 @@ export default function ToolNew() {
|
||||
placeholder="What does this tool do? Why do people use it?"
|
||||
className="min-h-[120px] resize-none"
|
||||
{...field}
|
||||
data-testid="input-tool-description"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -173,13 +189,14 @@ 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"
|
||||
size="sm"
|
||||
onClick={() => appendFeature({ value: "" })}
|
||||
data-testid="button-add-feature"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||
</Button>
|
||||
@@ -194,7 +211,12 @@ 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"
|
||||
@@ -202,6 +224,7 @@ export default function ToolNew() {
|
||||
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>
|
||||
@@ -226,6 +249,7 @@ export default function ToolNew() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendTag({ value: "" })}
|
||||
data-testid="button-add-tag"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||
</Button>
|
||||
@@ -240,7 +264,12 @@ 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"
|
||||
@@ -248,6 +277,7 @@ export default function ToolNew() {
|
||||
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>
|
||||
|
||||
@@ -138,6 +138,16 @@ export interface RatingDistribution {
|
||||
usability: ScoreBucket[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
sub: string;
|
||||
/** @nullable */
|
||||
email?: string | null;
|
||||
/** @nullable */
|
||||
name?: string | null;
|
||||
/** @nullable */
|
||||
preferredUsername?: string | null;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
|
||||
import type {
|
||||
AnalyticsSummary,
|
||||
AuthUser,
|
||||
CategoryStats,
|
||||
ErrorResponse,
|
||||
GetRatingDistributionParams,
|
||||
@@ -1049,3 +1050,157 @@ export function useListCategories<TData = Awaited<ReturnType<typeof listCategori
|
||||
|
||||
|
||||
|
||||
export const getListAllFeaturesUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/features/all`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List all distinct feature strings across all tools
|
||||
*/
|
||||
export const listAllFeatures = async ( options?: RequestInit): Promise<string[]> => {
|
||||
|
||||
return customFetch<string[]>(getListAllFeaturesUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getListAllFeaturesQueryKey = () => {
|
||||
return [
|
||||
`/api/features/all`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getListAllFeaturesQueryOptions = <TData = Awaited<ReturnType<typeof listAllFeatures>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listAllFeatures>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListAllFeaturesQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAllFeatures>>> = ({ signal }) => listAllFeatures({ signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listAllFeatures>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type ListAllFeaturesQueryResult = NonNullable<Awaited<ReturnType<typeof listAllFeatures>>>
|
||||
export type ListAllFeaturesQueryError = ErrorType<unknown>
|
||||
|
||||
|
||||
/**
|
||||
* @summary List all distinct feature strings across all tools
|
||||
*/
|
||||
|
||||
export function useListAllFeatures<TData = Awaited<ReturnType<typeof listAllFeatures>>, TError = ErrorType<unknown>>(
|
||||
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listAllFeatures>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
|
||||
const queryOptions = getListAllFeaturesQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetMeUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/auth/me`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get current authenticated user
|
||||
*/
|
||||
export const getMe = async ( options?: RequestInit): Promise<AuthUser> => {
|
||||
|
||||
return customFetch<AuthUser>(getGetMeUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetMeQueryKey = () => {
|
||||
return [
|
||||
`/api/auth/me`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetMeQueryOptions = <TData = Awaited<ReturnType<typeof getMe>>, TError = ErrorType<ErrorResponse>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMeQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMe>>> = ({ signal }) => getMe({ signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type GetMeQueryResult = NonNullable<Awaited<ReturnType<typeof getMe>>>
|
||||
export type GetMeQueryError = ErrorType<ErrorResponse>
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get current authenticated user
|
||||
*/
|
||||
|
||||
export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = ErrorType<ErrorResponse>>(
|
||||
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
|
||||
const queryOptions = getGetMeQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -304,6 +304,40 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/features/all:
|
||||
get:
|
||||
operationId: listAllFeatures
|
||||
tags: [tools]
|
||||
summary: List all distinct feature strings across all tools
|
||||
responses:
|
||||
"200":
|
||||
description: All known features
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/auth/me:
|
||||
get:
|
||||
operationId: getMe
|
||||
tags: [auth]
|
||||
summary: Get current authenticated user
|
||||
responses:
|
||||
"200":
|
||||
description: Current user info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthUser"
|
||||
"401":
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HealthStatus:
|
||||
@@ -533,6 +567,19 @@ components:
|
||||
count:
|
||||
type: integer
|
||||
|
||||
AuthUser:
|
||||
type: object
|
||||
required: [sub]
|
||||
properties:
|
||||
sub:
|
||||
type: string
|
||||
email:
|
||||
type: ["string", "null"]
|
||||
name:
|
||||
type: ["string", "null"]
|
||||
preferredUsername:
|
||||
type: ["string", "null"]
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [error]
|
||||
|
||||
@@ -269,3 +269,21 @@ export const ListCategoriesResponseItem = zod.string()
|
||||
export const ListCategoriesResponse = zod.array(ListCategoriesResponseItem)
|
||||
|
||||
|
||||
/**
|
||||
* @summary List all distinct feature strings across all tools
|
||||
*/
|
||||
export const ListAllFeaturesResponseItem = zod.string()
|
||||
export const ListAllFeaturesResponse = zod.array(ListAllFeaturesResponseItem)
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get current authenticated user
|
||||
*/
|
||||
export const GetMeResponse = zod.object({
|
||||
"sub": zod.string(),
|
||||
"email": zod.string().nullish(),
|
||||
"name": zod.string().nullish(),
|
||||
"preferredUsername": zod.string().nullish()
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface AuthUser {
|
||||
sub: string;
|
||||
/** @nullable */
|
||||
email?: string | null;
|
||||
/** @nullable */
|
||||
name?: string | null;
|
||||
/** @nullable */
|
||||
preferredUsername?: string | null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
export * from './analyticsSummary';
|
||||
export * from './authUser';
|
||||
export * from './categoryStats';
|
||||
export * from './errorResponse';
|
||||
export * from './getRatingDistributionParams';
|
||||
|
||||
Generated
+146
@@ -173,6 +173,9 @@ importers:
|
||||
'@workspace/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../lib/db
|
||||
connect-pg-simple:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
cookie-parser:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
@@ -185,6 +188,12 @@ importers:
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
express-session:
|
||||
specifier: ^1.19.0
|
||||
version: 1.19.0
|
||||
openid-client:
|
||||
specifier: ^5.7.1
|
||||
version: 5.7.1
|
||||
pino:
|
||||
specifier: ^9.14.0
|
||||
version: 9.14.0
|
||||
@@ -192,6 +201,9 @@ importers:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0
|
||||
devDependencies:
|
||||
'@types/connect-pg-simple':
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
'@types/cookie-parser':
|
||||
specifier: ^1.4.10
|
||||
version: 1.4.10(@types/express@5.0.6)
|
||||
@@ -201,6 +213,9 @@ importers:
|
||||
'@types/express':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
'@types/express-session':
|
||||
specifier: ^1.19.0
|
||||
version: 1.19.0
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 25.6.2
|
||||
@@ -1629,6 +1644,9 @@ packages:
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/connect-pg-simple@7.0.3':
|
||||
resolution: {integrity: sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
@@ -1673,6 +1691,9 @@ packages:
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
|
||||
|
||||
'@types/express-session@1.19.0':
|
||||
resolution: {integrity: sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==}
|
||||
|
||||
'@types/express@5.0.6':
|
||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||
|
||||
@@ -1831,6 +1852,10 @@ packages:
|
||||
compare-versions@6.1.1:
|
||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||
|
||||
connect-pg-simple@10.0.0:
|
||||
resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0}
|
||||
|
||||
content-disposition@1.1.0:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1849,6 +1874,9 @@ packages:
|
||||
cookie-signature@1.0.6:
|
||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||
|
||||
cookie-signature@1.0.7:
|
||||
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
@@ -1929,6 +1957,14 @@ packages:
|
||||
dateformat@4.6.3:
|
||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2151,6 +2187,10 @@ packages:
|
||||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||
engines: {node: ^18.19.0 || >=20.5.0}
|
||||
|
||||
express-session@1.19.0:
|
||||
resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -2361,6 +2401,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@4.15.9:
|
||||
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2424,6 +2467,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lru-cache@6.0.0:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lucide-react@0.545.0:
|
||||
resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==}
|
||||
peerDependencies:
|
||||
@@ -2489,6 +2536,9 @@ packages:
|
||||
motion-utils@12.36.0:
|
||||
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -2518,10 +2568,18 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-hash@2.2.0:
|
||||
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
oidc-token-hash@5.2.0:
|
||||
resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==}
|
||||
engines: {node: ^10.13.0 || >=12.0.0}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -2530,9 +2588,16 @@ packages:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
on-headers@1.1.0:
|
||||
resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
openid-client@5.7.1:
|
||||
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||
|
||||
orval@8.9.1:
|
||||
resolution: {integrity: sha512-o9hgALCr3mCXOWxfct4IrsmlNtURt1iwDx9TecbPT5pwoXZMRpdT6W8/ED78NY0ZNnKs6L00QRNLN1SOArnU+w==}
|
||||
engines: {node: '>=22.18.0'}
|
||||
@@ -2698,6 +2763,10 @@ packages:
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
random-bytes@1.0.0:
|
||||
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2808,6 +2877,7 @@ packages:
|
||||
recharts@2.15.4:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -2842,6 +2912,9 @@ packages:
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3024,6 +3097,10 @@ packages:
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
uid-safe@2.1.5:
|
||||
resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
@@ -3150,6 +3227,9 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yallist@4.0.0:
|
||||
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
@@ -4307,6 +4387,12 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.6.2
|
||||
|
||||
'@types/connect-pg-simple@7.0.3':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
'@types/express-session': 1.19.0
|
||||
'@types/pg': 8.20.0
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.6.2
|
||||
@@ -4352,6 +4438,10 @@ snapshots:
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
||||
'@types/express-session@1.19.0':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
|
||||
'@types/express@5.0.6':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
@@ -4523,6 +4613,12 @@ snapshots:
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
|
||||
connect-pg-simple@10.0.0:
|
||||
dependencies:
|
||||
pg: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
- pg-native
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
@@ -4536,6 +4632,8 @@ snapshots:
|
||||
|
||||
cookie-signature@1.0.6: {}
|
||||
|
||||
cookie-signature@1.0.7: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
@@ -4601,6 +4699,10 @@ snapshots:
|
||||
|
||||
dateformat@4.6.3: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -4720,6 +4822,19 @@ snapshots:
|
||||
strip-final-newline: 4.0.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
express-session@1.19.0:
|
||||
dependencies:
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.0.7
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
on-headers: 1.1.0
|
||||
parseurl: 1.3.3
|
||||
safe-buffer: 5.2.1
|
||||
uid-safe: 2.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@@ -4934,6 +5049,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@4.15.9: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -4985,6 +5102,10 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru-cache@6.0.0:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
lucide-react@0.545.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -5041,6 +5162,8 @@ snapshots:
|
||||
|
||||
motion-utils@12.36.0: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
@@ -5061,18 +5184,31 @@ snapshots:
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@2.2.0: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
oidc-token-hash@5.2.0: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
on-headers@1.1.0: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
openid-client@5.7.1:
|
||||
dependencies:
|
||||
jose: 4.15.9
|
||||
lru-cache: 6.0.0
|
||||
object-hash: 2.2.0
|
||||
oidc-token-hash: 5.2.0
|
||||
|
||||
orval@8.9.1(prettier@3.8.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@commander-js/extra-typings': 14.0.0(commander@14.0.3)
|
||||
@@ -5274,6 +5410,8 @@ snapshots:
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
random-bytes@1.0.0: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
@@ -5415,6 +5553,8 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
@@ -5584,6 +5724,10 @@ snapshots:
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
uid-safe@2.1.5:
|
||||
dependencies:
|
||||
random-bytes: 1.0.0
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
@@ -5682,6 +5826,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
yocto-queue@1.2.2: {}
|
||||
|
||||
@@ -1,36 +1,70 @@
|
||||
# [Project name]
|
||||
# ToolRate
|
||||
|
||||
_Replace the heading above with the project's name, and this line with one sentence describing what this app does for users._
|
||||
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
||||
|
||||
## Run & Operate
|
||||
|
||||
- `pnpm --filter @workspace/api-server run dev` — run the API server (port 5000)
|
||||
- `pnpm --filter @workspace/api-server run dev` — run the API server (port 8080)
|
||||
- `pnpm --filter @workspace/toolrate run dev` — run the frontend (port 26015)
|
||||
- `pnpm run typecheck` — full typecheck across all packages
|
||||
- `pnpm run build` — typecheck + build all packages
|
||||
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
||||
- `pnpm --filter @workspace/db run push` — push DB schema changes (dev only)
|
||||
- Required env: `DATABASE_URL` — Postgres connection string
|
||||
|
||||
## Required env vars
|
||||
|
||||
- `DATABASE_URL` — Postgres connection string (auto-provisioned)
|
||||
- `SESSION_SECRET` — Session signing secret (already set)
|
||||
|
||||
## Keycloak (optional)
|
||||
|
||||
Set these to enable login:
|
||||
- `KEYCLOAK_URL` — e.g. `https://auth.example.com`
|
||||
- `KEYCLOAK_REALM` — realm name
|
||||
- `KEYCLOAK_CLIENT_ID` — client ID
|
||||
- `KEYCLOAK_CLIENT_SECRET` — client secret
|
||||
- `APP_URL` — public base URL for OAuth callback (optional, auto-detected if omitted)
|
||||
|
||||
Without these, the app runs in read-only mode (browsing and viewing ratings works, submitting tools/ratings requires login).
|
||||
|
||||
## Stack
|
||||
|
||||
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
||||
- API: Express 5
|
||||
- API: Express 5 + openid-client (Keycloak OIDC) + express-session + connect-pg-simple
|
||||
- DB: PostgreSQL + Drizzle ORM
|
||||
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
||||
- API codegen: Orval (from OpenAPI spec)
|
||||
- Build: esbuild (CJS bundle)
|
||||
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
||||
|
||||
## Where things live
|
||||
|
||||
_Populate as you build — short repo map plus pointers to the source-of-truth file for DB schema, API contracts, theme files, etc._
|
||||
- `lib/api-spec/openapi.yaml` — source of truth for the API contract
|
||||
- `lib/db/src/schema/` — Drizzle table definitions (`tools.ts`, `ratings.ts`)
|
||||
- `lib/api-client-react/src/generated/` — generated React Query hooks (do not edit)
|
||||
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
||||
- `artifacts/api-server/src/routes/` — Express route handlers
|
||||
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
||||
- `artifacts/api-server/src/routes/auth.ts` — Keycloak OIDC login/callback/logout/me
|
||||
- `artifacts/toolrate/src/pages/` — frontend pages
|
||||
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
||||
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
||||
|
||||
## Architecture decisions
|
||||
|
||||
_Populate as you build — non-obvious choices a reader couldn't infer from the code (3-5 bullets)._
|
||||
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
||||
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
||||
- Write operations (create/update/delete tools, submit ratings) require auth. Reads are public.
|
||||
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
||||
- Grafana can consume `/api/analytics/*` endpoints directly via JSON datasource plugin.
|
||||
|
||||
## Product
|
||||
|
||||
_Describe the high-level user-facing capabilities of this app once they exist._
|
||||
- Browse and search tools by category, with ratings (usefulness 1-5 + usability 1-5)
|
||||
- Submit new tools with features and tags
|
||||
- Rate tools with comment and reviewer name
|
||||
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
||||
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
||||
|
||||
## User preferences
|
||||
|
||||
@@ -38,7 +72,9 @@ _Populate as you build — explicit user instructions worth remembering across s
|
||||
|
||||
## Gotchas
|
||||
|
||||
_Populate as you build — sharp edges, "always run X before Y" rules._
|
||||
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
||||
- Auth routes use PKCE — the code_verifier is stored in the session, not in-memory state.
|
||||
- `trust proxy: 1` is set on Express so that session cookies work correctly behind Replit's reverse proxy.
|
||||
|
||||
## Pointers
|
||||
|
||||
|
||||
Reference in New Issue
Block a user