Add local user authentication and admin capabilities
Implement local user authentication with password hashing, add admin roles for user management and audit log viewing, and introduce audit logging for critical actions. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 832a44ff-12ae-4096-8a0d-666ec083d536 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/1p7jhzu Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@workspace/api-zod": "workspace:*",
|
"@workspace/api-zod": "workspace:*",
|
||||||
"@workspace/db": "workspace:*",
|
"@workspace/db": "workspace:*",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"pino-http": "^10.5.0"
|
"pino-http": "^10.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
"@types/connect-pg-simple": "^7.0.3",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import app from "./app";
|
import app from "./app";
|
||||||
import { logger } from "./lib/logger";
|
import { logger } from "./lib/logger";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { db, usersTable } from "@workspace/db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
const rawPort = process.env["PORT"];
|
const rawPort = process.env["PORT"];
|
||||||
|
|
||||||
@@ -15,6 +18,30 @@ if (Number.isNaN(port) || port <= 0) {
|
|||||||
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedAdminUser(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [row] = await db.select({ count: sql<number>`count(*)::int` }).from(usersTable);
|
||||||
|
if ((row?.count ?? 0) > 0) return;
|
||||||
|
|
||||||
|
const adminUsername = process.env.LOCAL_ADMIN_USERNAME || "admin";
|
||||||
|
const adminPassword = process.env.LOCAL_ADMIN_PASSWORD;
|
||||||
|
if (!adminPassword) {
|
||||||
|
logger.warn("LOCAL_ADMIN_PASSWORD is not set — skipping admin seed. Set it to enable local login.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
||||||
|
await db.insert(usersTable).values({
|
||||||
|
username: adminUsername,
|
||||||
|
passwordHash,
|
||||||
|
role: "admin",
|
||||||
|
});
|
||||||
|
logger.info({ username: adminUsername }, "Admin user created");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to seed admin user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.listen(port, (err) => {
|
app.listen(port, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error({ err }, "Error listening on port");
|
logger.error({ err }, "Error listening on port");
|
||||||
@@ -22,4 +49,5 @@ app.listen(port, (err) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ port }, "Server listening");
|
logger.info({ port }, "Server listening");
|
||||||
|
seedAdminUser();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { db, auditLogsTable } from "@workspace/db";
|
||||||
|
import { type Request } from "express";
|
||||||
|
|
||||||
|
export async function writeAuditLog(
|
||||||
|
req: Request,
|
||||||
|
entityType: string,
|
||||||
|
entityId: number | null,
|
||||||
|
action: string,
|
||||||
|
changes?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = req.session.user;
|
||||||
|
if (!user) return;
|
||||||
|
const username = user.preferred_username || user.name || user.sub;
|
||||||
|
await db.insert(auditLogsTable).values({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
action,
|
||||||
|
userId: user.sub,
|
||||||
|
username,
|
||||||
|
changes: changes ? JSON.stringify(changes) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,3 +7,15 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.session.user.role !== "admin") {
|
||||||
|
res.status(403).json({ error: "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Router, type IRouter } from "express";
|
||||||
|
import { eq, desc, and } from "drizzle-orm";
|
||||||
|
import { db, auditLogsTable } from "@workspace/db";
|
||||||
|
import { requireAdmin } from "../middleware/auth";
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
router.get("/audit-logs", requireAdmin, async (req, res): Promise<void> => {
|
||||||
|
const { entityType, entityId, limit } = req.query;
|
||||||
|
const maxLimit = Math.min(parseInt(limit as string) || 100, 500);
|
||||||
|
|
||||||
|
const conditions: ReturnType<typeof eq>[] = [];
|
||||||
|
if (entityType && typeof entityType === "string") {
|
||||||
|
conditions.push(eq(auditLogsTable.entityType, entityType));
|
||||||
|
}
|
||||||
|
if (entityId) {
|
||||||
|
const id = parseInt(entityId as string, 10);
|
||||||
|
if (!isNaN(id)) {
|
||||||
|
conditions.push(eq(auditLogsTable.entityId, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await db
|
||||||
|
.select()
|
||||||
|
.from(auditLogsTable)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(auditLogsTable.createdAt))
|
||||||
|
.limit(maxLimit);
|
||||||
|
|
||||||
|
res.json(logs);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
import { Router, type IRouter, type Request } from "express";
|
import { Router, type IRouter, type Request } from "express";
|
||||||
import { Issuer, generators, type Client } from "openid-client";
|
import { Issuer, generators, type Client } from "openid-client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db, usersTable } from "@workspace/db";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
let cachedClient: Client | null = null;
|
let cachedClient: Client | null = null;
|
||||||
|
|
||||||
|
function isOidcConfigured(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.KEYCLOAK_URL &&
|
||||||
|
process.env.KEYCLOAK_REALM &&
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID &&
|
||||||
|
process.env.KEYCLOAK_CLIENT_SECRET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getBaseUrl(req: Request): string {
|
function getBaseUrl(req: Request): string {
|
||||||
if (process.env.APP_URL) return process.env.APP_URL;
|
if (process.env.APP_URL) return process.env.APP_URL;
|
||||||
const host = req.get("x-forwarded-host") || req.get("host") || "localhost";
|
const host = req.get("x-forwarded-host") || req.get("host") || "localhost";
|
||||||
@@ -40,10 +52,62 @@ async function getClient(): Promise<Client | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.get("/auth/mode", (_req, res): void => {
|
||||||
|
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/login", async (req, res): Promise<void> => {
|
||||||
|
if (isOidcConfigured()) {
|
||||||
|
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).json({ error: "username and password are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.username, String(username)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: "Invalid username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(String(password), user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ error: "Invalid username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
sub: String(user.id),
|
||||||
|
name: user.username,
|
||||||
|
preferred_username: user.username,
|
||||||
|
email: user.email ?? undefined,
|
||||||
|
role: (user.role as "admin" | "user") ?? "user",
|
||||||
|
isLocal: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sub: String(user.id),
|
||||||
|
email: user.email ?? null,
|
||||||
|
name: user.username,
|
||||||
|
preferredUsername: user.username,
|
||||||
|
role: user.role,
|
||||||
|
isLocal: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/auth/login", async (req, res): Promise<void> => {
|
router.get("/auth/login", async (req, res): Promise<void> => {
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
res.status(503).json({ error: "Keycloak is not configured. Set KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET." });
|
res.status(503).json({ error: "Keycloak is not configured." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +158,8 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
|||||||
email: typeof userinfo.email === "string" ? userinfo.email : undefined,
|
email: typeof userinfo.email === "string" ? userinfo.email : undefined,
|
||||||
name: typeof userinfo.name === "string" ? userinfo.name : undefined,
|
name: typeof userinfo.name === "string" ? userinfo.name : undefined,
|
||||||
preferred_username: typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined,
|
preferred_username: typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined,
|
||||||
|
role: "user",
|
||||||
|
isLocal: false,
|
||||||
};
|
};
|
||||||
delete req.session.codeVerifier;
|
delete req.session.codeVerifier;
|
||||||
|
|
||||||
@@ -108,7 +174,6 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get("/auth/logout", async (req, res): Promise<void> => {
|
router.get("/auth/logout", async (req, res): Promise<void> => {
|
||||||
const user = req.session.user;
|
|
||||||
req.session.destroy(() => {});
|
req.session.destroy(() => {});
|
||||||
|
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
@@ -132,6 +197,8 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
|||||||
email: u.email ?? null,
|
email: u.email ?? null,
|
||||||
name: u.name ?? null,
|
name: u.name ?? null,
|
||||||
preferredUsername: u.preferred_username ?? null,
|
preferredUsername: u.preferred_username ?? null,
|
||||||
|
role: u.role ?? "user",
|
||||||
|
isLocal: u.isLocal ?? false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import toolsRouter from "./tools";
|
|||||||
import ratingsRouter from "./ratings";
|
import ratingsRouter from "./ratings";
|
||||||
import analyticsRouter from "./analytics";
|
import analyticsRouter from "./analytics";
|
||||||
import authRouter from "./auth";
|
import authRouter from "./auth";
|
||||||
|
import usersRouter from "./users";
|
||||||
|
import auditRouter from "./audit";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -12,5 +14,7 @@ router.use(healthRouter);
|
|||||||
router.use(toolsRouter);
|
router.use(toolsRouter);
|
||||||
router.use(ratingsRouter);
|
router.use(ratingsRouter);
|
||||||
router.use(analyticsRouter);
|
router.use(analyticsRouter);
|
||||||
|
router.use(usersRouter);
|
||||||
|
router.use(auditRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DeleteToolParams,
|
DeleteToolParams,
|
||||||
} from "@workspace/api-zod";
|
} from "@workspace/api-zod";
|
||||||
import { requireAuth } from "../middleware/auth";
|
import { requireAuth } from "../middleware/auth";
|
||||||
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -27,6 +28,13 @@ function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { use
|
|||||||
return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined };
|
return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canEditTool(req: import("express").Request, tool: { createdBy: string | null }): boolean {
|
||||||
|
const user = req.session.user;
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.role === "admin") return true;
|
||||||
|
return tool.createdBy === user.sub || tool.createdBy === user.preferred_username;
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/tools", async (req, res): Promise<void> => {
|
router.get("/tools", async (req, res): Promise<void> => {
|
||||||
const parsed = ListToolsQueryParams.safeParse(req.query);
|
const parsed = ListToolsQueryParams.safeParse(req.query);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -79,16 +87,21 @@ router.post("/tools", requireAuth, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = req.session.user!;
|
||||||
|
const createdBy = user.preferred_username || user.name || user.sub;
|
||||||
|
|
||||||
const [tool] = await db.insert(toolsTable).values({
|
const [tool] = await db.insert(toolsTable).values({
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
description: parsed.data.description,
|
description: parsed.data.description,
|
||||||
category: parsed.data.category,
|
category: parsed.data.category,
|
||||||
websiteUrl: parsed.data.websiteUrl ?? null,
|
websiteUrl: parsed.data.websiteUrl ?? null,
|
||||||
iconUrl: parsed.data.iconUrl ?? null,
|
iconUrl: parsed.data.iconUrl ?? null,
|
||||||
|
createdBy,
|
||||||
features: parsed.data.features ?? [],
|
features: parsed.data.features ?? [],
|
||||||
tags: parsed.data.tags ?? [],
|
tags: parsed.data.tags ?? [],
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
|
await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category });
|
||||||
res.status(201).json(tool);
|
res.status(201).json(tool);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,6 +133,21 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(toolsTable)
|
||||||
|
.where(eq(toolsTable.id, params.data.id));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Tool not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEditTool(req, existing)) {
|
||||||
|
res.status(403).json({ error: "Not allowed to edit this tool" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = UpdateToolBody.safeParse(req.body);
|
const parsed = UpdateToolBody.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: parsed.error.message });
|
res.status(400).json({ error: parsed.error.message });
|
||||||
@@ -127,13 +155,28 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (parsed.data.name !== undefined) updateData.name = parsed.data.name;
|
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
||||||
if (parsed.data.description !== undefined) updateData.description = parsed.data.description;
|
|
||||||
if (parsed.data.category !== undefined) updateData.category = parsed.data.category;
|
function track(key: keyof typeof existing, value: unknown) {
|
||||||
if (parsed.data.websiteUrl !== undefined) updateData.websiteUrl = parsed.data.websiteUrl;
|
if (value !== undefined && value !== existing[key]) {
|
||||||
if (parsed.data.iconUrl !== undefined) updateData.iconUrl = parsed.data.iconUrl;
|
changes[key] = { from: existing[key], to: value };
|
||||||
if (parsed.data.features !== undefined) updateData.features = parsed.data.features;
|
(updateData as Record<string, unknown>)[key] = value;
|
||||||
if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
track("name", parsed.data.name);
|
||||||
|
track("description", parsed.data.description);
|
||||||
|
track("category", parsed.data.category);
|
||||||
|
track("websiteUrl", parsed.data.websiteUrl);
|
||||||
|
track("iconUrl", parsed.data.iconUrl);
|
||||||
|
if (parsed.data.features !== undefined) {
|
||||||
|
changes["features"] = { from: existing.features, to: parsed.data.features };
|
||||||
|
updateData.features = parsed.data.features;
|
||||||
|
}
|
||||||
|
if (parsed.data.tags !== undefined) {
|
||||||
|
changes["tags"] = { from: existing.tags, to: parsed.data.tags };
|
||||||
|
updateData.tags = parsed.data.tags;
|
||||||
|
}
|
||||||
|
|
||||||
const [tool] = await db
|
const [tool] = await db
|
||||||
.update(toolsTable)
|
.update(toolsTable)
|
||||||
@@ -146,6 +189,10 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
await writeAuditLog(req, "tool", tool.id, "update", changes);
|
||||||
|
}
|
||||||
|
|
||||||
res.json(tool);
|
res.json(tool);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,12 +203,23 @@ router.delete("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [tool] = await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id)).returning();
|
const [existing] = await db
|
||||||
if (!tool) {
|
.select()
|
||||||
|
.from(toolsTable)
|
||||||
|
.where(eq(toolsTable.id, params.data.id));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
res.status(404).json({ error: "Tool not found" });
|
res.status(404).json({ error: "Tool not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canEditTool(req, existing)) {
|
||||||
|
res.status(403).json({ error: "Not allowed to delete this tool" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name });
|
||||||
|
await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id));
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Router, type IRouter } from "express";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { db, usersTable } from "@workspace/db";
|
||||||
|
import { requireAdmin } from "../middleware/auth";
|
||||||
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
const UserCreateSchema = z.object({
|
||||||
|
username: z.string().min(2),
|
||||||
|
password: z.string().min(6),
|
||||||
|
email: z.string().optional(),
|
||||||
|
role: z.enum(["admin", "user"]).optional().default("user"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserRoleUpdateSchema = z.object({
|
||||||
|
role: z.enum(["admin", "user"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||||
|
const users = await db
|
||||||
|
.select({
|
||||||
|
id: usersTable.id,
|
||||||
|
username: usersTable.username,
|
||||||
|
email: usersTable.email,
|
||||||
|
role: usersTable.role,
|
||||||
|
createdAt: usersTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(usersTable)
|
||||||
|
.orderBy(usersTable.createdAt);
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||||
|
const parsed = UserCreateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: usersTable.id })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.username, parsed.data.username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
res.status(409).json({ error: "Username already exists" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
||||||
|
const [user] = await db
|
||||||
|
.insert(usersTable)
|
||||||
|
.values({
|
||||||
|
username: parsed.data.username,
|
||||||
|
passwordHash,
|
||||||
|
email: parsed.data.email ?? null,
|
||||||
|
role: parsed.data.role ?? "user",
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: usersTable.id,
|
||||||
|
username: usersTable.username,
|
||||||
|
email: usersTable.email,
|
||||||
|
role: usersTable.role,
|
||||||
|
createdAt: usersTable.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeAuditLog(req, "user", user.id, "create", { username: user.username, role: user.role });
|
||||||
|
res.status(201).json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UserRoleUpdateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.update(usersTable)
|
||||||
|
.set({ role: parsed.data.role })
|
||||||
|
.where(eq(usersTable.id, id))
|
||||||
|
.returning({
|
||||||
|
id: usersTable.id,
|
||||||
|
username: usersTable.username,
|
||||||
|
email: usersTable.email,
|
||||||
|
role: usersTable.role,
|
||||||
|
createdAt: usersTable.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(req, "user", id, "update", { role: parsed.data.role });
|
||||||
|
res.json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.session.user?.sub === String(id)) {
|
||||||
|
res.status(400).json({ error: "Cannot delete your own account" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db.delete(usersTable).where(eq(usersTable.id, id)).returning();
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(req, "user", id, "delete", { username: user.username });
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+2
@@ -7,6 +7,8 @@ declare module "express-session" {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
preferred_username?: string;
|
preferred_username?: string;
|
||||||
|
role?: "admin" | "user";
|
||||||
|
isLocal?: boolean;
|
||||||
};
|
};
|
||||||
codeVerifier?: string;
|
codeVerifier?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import Home from "@/pages/home";
|
|||||||
import ToolsBrowse from "@/pages/tools-browse";
|
import ToolsBrowse from "@/pages/tools-browse";
|
||||||
import ToolDetail from "@/pages/tool-detail";
|
import ToolDetail from "@/pages/tool-detail";
|
||||||
import ToolNew from "@/pages/tool-new";
|
import ToolNew from "@/pages/tool-new";
|
||||||
|
import ToolEdit from "@/pages/tool-edit";
|
||||||
import Analytics from "@/pages/analytics";
|
import Analytics from "@/pages/analytics";
|
||||||
|
import Admin from "@/pages/admin";
|
||||||
|
import Login from "@/pages/login";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -24,10 +27,13 @@ function Router() {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
|
<Route path="/login" component={Login} />
|
||||||
<Route path="/tools" component={ToolsBrowse} />
|
<Route path="/tools" component={ToolsBrowse} />
|
||||||
<Route path="/tools/new" component={ToolNew} />
|
<Route path="/tools/new" component={ToolNew} />
|
||||||
|
<Route path="/tools/:id/edit" component={ToolEdit} />
|
||||||
<Route path="/tools/:id" component={ToolDetail} />
|
<Route path="/tools/:id" component={ToolDetail} />
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={Analytics} />
|
||||||
|
<Route path="/admin" component={Admin} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User } from "lucide-react";
|
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck } from "lucide-react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { user, isLoading, isAuthenticated, login, logout } = useAuth();
|
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, login, logout } = useAuth();
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: "Browse Tools", icon: Wrench },
|
{ href: "/tools", label: "Browse Tools", icon: Wrench },
|
||||||
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
|
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
|
||||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||||
|
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,7 +83,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
data-testid="button-login"
|
data-testid="button-login"
|
||||||
>
|
>
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
Sign in with Keycloak
|
{isLocalMode ? "Sign in" : "Sign in with Keycloak"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,51 @@
|
|||||||
import { useGetMe } from "@workspace/api-client-react";
|
import { useGetMe, useGetAuthMode, getGetMeQueryKey, getGetAuthModeQueryKey } from "@workspace/api-client-react";
|
||||||
|
|
||||||
export type AuthUser = {
|
|
||||||
sub: string;
|
|
||||||
email?: string | null;
|
|
||||||
name?: string | null;
|
|
||||||
preferredUsername?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const { data: user, isLoading, error } = useGetMe({
|
const { data: user, isLoading, error } = useGetMe({
|
||||||
query: {
|
query: {
|
||||||
|
queryKey: getGetMeQueryKey(),
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: authMode } = useGetAuthMode({
|
||||||
|
query: {
|
||||||
|
queryKey: getGetAuthModeQueryKey(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isAuthenticated = !!user && !error;
|
const isAuthenticated = !!user && !error;
|
||||||
|
const isAdmin = isAuthenticated && user?.role === "admin";
|
||||||
|
const isLocalMode = authMode?.mode === "local";
|
||||||
|
|
||||||
function login(returnTo?: string) {
|
function login(returnTo?: string) {
|
||||||
|
if (isLocalMode) {
|
||||||
|
const path = returnTo
|
||||||
|
? `/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||||
|
: "/login";
|
||||||
|
window.location.href = path;
|
||||||
|
} else {
|
||||||
const url = returnTo
|
const url = returnTo
|
||||||
? `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
? `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||||
: "/api/auth/login";
|
: "/api/auth/login";
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
window.location.href = "/api/auth/logout";
|
window.location.href = "/api/auth/logout";
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user: isAuthenticated ? user : null, isLoading, isAuthenticated, login, logout };
|
return {
|
||||||
|
user: isAuthenticated ? user : null,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
isLocalMode,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import {
|
||||||
|
useListUsers,
|
||||||
|
useCreateUser,
|
||||||
|
useUpdateUser,
|
||||||
|
useDeleteUser,
|
||||||
|
useListAuditLogs,
|
||||||
|
getListUsersQueryKey,
|
||||||
|
getListAuditLogsQueryKey,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Layout } from "@/components/layout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const { user, isAdmin, isLoading: authLoading } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string } | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||||
|
|
||||||
|
const [newUsername, setNewUsername] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [newRole, setNewRole] = useState<"admin" | "user">("user");
|
||||||
|
|
||||||
|
const { data: users, isLoading: loadingUsers } = useListUsers({
|
||||||
|
query: { queryKey: getListUsersQueryKey(), enabled: isAdmin },
|
||||||
|
});
|
||||||
|
const { data: auditLogs, isLoading: loadingLogs } = useListAuditLogs(
|
||||||
|
{ limit: 100 },
|
||||||
|
{ query: { queryKey: getListAuditLogsQueryKey({ limit: 100 }), enabled: isAdmin } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const createUser = useCreateUser();
|
||||||
|
const updateUser = useUpdateUser();
|
||||||
|
const deleteUser = useDeleteUser();
|
||||||
|
|
||||||
|
if (!authLoading && !isAdmin) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold">Admin Access Required</h2>
|
||||||
|
<p className="text-muted-foreground">You need admin rights to view this page.</p>
|
||||||
|
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = () => {
|
||||||
|
if (!newUsername || !newPassword) return;
|
||||||
|
createUser.mutate(
|
||||||
|
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "User created", description: `${newUsername} has been created.` });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setNewUsername("");
|
||||||
|
setNewPassword("");
|
||||||
|
setNewEmail("");
|
||||||
|
setNewRole("user");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: "Failed to create user", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateRole = (role: "admin" | "user") => {
|
||||||
|
if (!editUser) return;
|
||||||
|
updateUser.mutate(
|
||||||
|
{ id: editUser.id, data: { role } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Role updated" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
|
setEditUser(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: "Failed to update role", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = () => {
|
||||||
|
if (!deleteConfirm) return;
|
||||||
|
deleteUser.mutate(
|
||||||
|
{ id: deleteConfirm.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "User deleted", description: `${deleteConfirm.username} has been removed.` });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: "Failed to delete user", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function actionBadgeVariant(action: string): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
if (action === "create") return "default";
|
||||||
|
if (action === "delete") return "destructive";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
|
||||||
|
<p className="text-muted-foreground">Manage users and review system changes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="users">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="users" className="gap-2">
|
||||||
|
<Users className="w-4 h-4" /> Users
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit" className="gap-2">
|
||||||
|
<ScrollText className="w-4 h-4" /> Audit Log
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="users">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Local Users</CardTitle>
|
||||||
|
<CardDescription>Manage accounts for local authentication.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> Add User
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{users?.map((u) => (
|
||||||
|
<div key={u.id} className="flex items-center justify-between py-3 gap-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0 font-medium text-primary text-sm">
|
||||||
|
{u.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{u.username}</p>
|
||||||
|
{u.email && <p className="text-xs text-muted-foreground truncate">{u.email}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
|
||||||
|
{u.role}
|
||||||
|
</Badge>
|
||||||
|
{u.username !== user?.preferredUsername && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role })}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setDeleteConfirm({ id: u.id, username: u.username })}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!users || users.length === 0) && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">No users yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="audit">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit Log</CardTitle>
|
||||||
|
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingLogs ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{auditLogs?.map((log) => (
|
||||||
|
<div key={log.id} className="py-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant={actionBadgeVariant(log.action)} className="capitalize text-xs">
|
||||||
|
{log.action}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium capitalize">{log.entityType}</span>
|
||||||
|
{log.entityId && (
|
||||||
|
<span className="text-sm text-muted-foreground">#{log.entityId}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{format(new Date(log.createdAt), "dd.MM.yyyy HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
|
||||||
|
{log.changes && (
|
||||||
|
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
||||||
|
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!auditLogs || auditLogs.length === 0) && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">No audit entries yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email (Optional)</Label>
|
||||||
|
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
|
||||||
|
{createUser.isPending ? "Creating…" : "Create User"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change Role — {editUser?.username}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Select
|
||||||
|
value={editUser?.role || "user"}
|
||||||
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdateRole(editUser?.role as "admin" | "user")}
|
||||||
|
disabled={updateUser.isPending}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm?.username}</span>? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
|
||||||
|
{deleteUser.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,7 +36,9 @@ export default function Analytics() {
|
|||||||
const categoryChartData = categoryStats?.map(c => ({
|
const categoryChartData = categoryStats?.map(c => ({
|
||||||
category: c.category,
|
category: c.category,
|
||||||
tools: c.toolCount,
|
tools: c.toolCount,
|
||||||
avgScore: c.avgCombined ? Number(c.avgCombined.toFixed(2)) : 0
|
avgScore: (c.avgUsefulness != null && c.avgUsability != null)
|
||||||
|
? Number(((c.avgUsefulness + c.avgUsability) / 2).toFixed(2))
|
||||||
|
: 0
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const usefulnessData = distribution?.usefulness.map(b => ({ score: `${b.score} Star`, count: b.count })) || [];
|
const usefulnessData = distribution?.usefulness.map(b => ({ score: `${b.score} Star`, count: b.count })) || [];
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation, useSearch } from "wouter";
|
||||||
|
import { useLocalLogin } from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Wrench, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const search = useSearch();
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const returnTo = params.get("returnTo") || "/";
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const localLogin = useLocalLogin();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
localLogin.mutate(
|
||||||
|
{ data: { username, password } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
setLocation(returnTo);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.data?.error || err.message || "Invalid username or password");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<div className="flex items-center gap-2 text-primary font-bold text-2xl">
|
||||||
|
<Wrench className="w-7 h-7" />
|
||||||
|
<span>ToolRate</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-lg">Sign in</CardTitle>
|
||||||
|
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-destructive text-sm rounded-md bg-destructive/10 px-3 py-2">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={localLogin.isPending}
|
||||||
|
>
|
||||||
|
{localLogin.isPending ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,8 +27,20 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
import { ExternalLink, Star, ArrowLeft, Plus } from "lucide-react";
|
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
const ratingSchema = z.object({
|
const ratingSchema = z.object({
|
||||||
usefulness: z.number().min(1).max(5),
|
usefulness: z.number().min(1).max(5),
|
||||||
@@ -47,6 +59,10 @@ export default function ToolDetail() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isReviewFormOpen, setIsReviewFormOpen] = useState(false);
|
const [isReviewFormOpen, setIsReviewFormOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
const { user, isAdmin } = useAuth();
|
||||||
|
const deleteTool = useDeleteTool();
|
||||||
|
|
||||||
const { data: tool, isLoading: loadingTool } = useGetTool(id, {
|
const { data: tool, isLoading: loadingTool } = useGetTool(id, {
|
||||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
||||||
@@ -99,7 +115,7 @@ export default function ToolDetail() {
|
|||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Failed to submit rating",
|
title: "Failed to submit rating",
|
||||||
description: error.error || "An unexpected error occurred.",
|
description: error.data?.error || error.message || "An unexpected error occurred.",
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,7 +138,32 @@ export default function ToolDetail() {
|
|||||||
const usefulnessData = distribution?.usefulness.map(b => ({ score: b.score, count: b.count })).reverse() || [];
|
const usefulnessData = distribution?.usefulness.map(b => ({ score: b.score, count: b.count })).reverse() || [];
|
||||||
const usabilityData = distribution?.usability.map(b => ({ score: b.score, count: b.count })).reverse() || [];
|
const usabilityData = distribution?.usability.map(b => ({ score: b.score, count: b.count })).reverse() || [];
|
||||||
|
|
||||||
|
function canEdit(toolData: { createdBy?: string | null }): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
if (isAdmin) return true;
|
||||||
|
return toolData.createdBy === user.sub ||
|
||||||
|
toolData.createdBy === user.preferredUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
deleteTool.mutate(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Tool deleted" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
|
setLocation("/tools");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: "Failed to delete", description: err.data?.error || err.message, variant: "destructive" });
|
||||||
|
setDeleteOpen(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||||
@@ -191,6 +232,24 @@ export default function ToolDetail() {
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canEdit(tool) && (
|
||||||
|
<div className="flex gap-2 w-full mt-1">
|
||||||
|
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
|
||||||
|
<Link href={`/tools/${id}/edit`}>
|
||||||
|
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -423,5 +482,27 @@ export default function ToolDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteTool.isPending}
|
||||||
|
>
|
||||||
|
{deleteTool.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRoute, useLocation } from "wouter";
|
||||||
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
import {
|
||||||
|
useGetTool,
|
||||||
|
useUpdateTool,
|
||||||
|
getGetToolQueryKey,
|
||||||
|
getListToolsQueryKey,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { Layout } from "@/components/layout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Pencil, Plus, X, ArrowLeft } from "lucide-react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { CategoryCombobox } from "@/components/category-combobox";
|
||||||
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
const toolSchema = z.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
|
description: z.string().min(10, "Description must be at least 10 characters"),
|
||||||
|
category: z.string().min(2, "Category is required"),
|
||||||
|
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
||||||
|
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
||||||
|
features: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ToolFormValues = z.infer<typeof toolSchema>;
|
||||||
|
|
||||||
|
export default function ToolEdit() {
|
||||||
|
const [match, params] = useRoute("/tools/:id/edit");
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const id = parseInt(params?.id || "0", 10);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const updateTool = useUpdateTool();
|
||||||
|
const { isAuthenticated, isAdmin } = useAuth();
|
||||||
|
|
||||||
|
const { data: tool, isLoading } = useGetTool(id, {
|
||||||
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<ToolFormValues>({
|
||||||
|
resolver: zodResolver(toolSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
category: "",
|
||||||
|
websiteUrl: "",
|
||||||
|
iconUrl: "",
|
||||||
|
features: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool) {
|
||||||
|
form.reset({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
category: tool.category,
|
||||||
|
websiteUrl: tool.websiteUrl || "",
|
||||||
|
iconUrl: tool.iconUrl || "",
|
||||||
|
features: (tool.features || []).map((v) => ({ value: v })),
|
||||||
|
tags: (tool.tags || []).map((v) => ({ value: v })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [tool, form]);
|
||||||
|
|
||||||
|
const { fields: featureFields, append: appendFeature, remove: removeFeature } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "features",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields: tagFields, append: appendTag, remove: removeTag } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "tags",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: ToolFormValues) => {
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
websiteUrl: data.websiteUrl || undefined,
|
||||||
|
iconUrl: data.iconUrl || undefined,
|
||||||
|
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
|
||||||
|
tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTool.mutate(
|
||||||
|
{ id, data: payload },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Tool updated", description: "Changes saved successfully." });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
|
setLocation(`/tools/${id}`);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({
|
||||||
|
title: "Failed to update tool",
|
||||||
|
description: err.data?.error || err.message || "An unexpected error occurred.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!match || isNaN(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||||
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||||
|
<Link href={`/tools/${id}`}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" /> Back to tool
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
|
||||||
|
<p className="text-muted-foreground">Update tool details and metadata.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Pencil className="w-5 h-5 text-primary" />
|
||||||
|
Tool Details
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Modify the tool information below.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Tool name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="category"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Category</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="websiteUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Website URL (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://..." type="url" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="iconUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{field.value ? (
|
||||||
|
<img
|
||||||
|
src={field.value}
|
||||||
|
alt="icon preview"
|
||||||
|
className="w-full h-full object-contain p-0.5"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">img</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
type="url"
|
||||||
|
{...field}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="What does this tool do?"
|
||||||
|
className="min-h-[120px] resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Features</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Key capabilities of this tool.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{featureFields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`features.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-start gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<FeatureInput
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="e.g. Real-time collaboration"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeFeature(index)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{featureFields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Tags</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Keywords for this tool.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tagFields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`tags.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-y-0 relative w-[150px]">
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Tag" className="pr-8 h-9 text-sm" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-9 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeTag(index)}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<Link href={`/tools/${id}`}>Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
|
||||||
|
{updateTool.isPending ? "Saving…" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -249,7 +249,7 @@ export default function ToolNew() {
|
|||||||
<FormItem className="flex items-start gap-2 space-y-0">
|
<FormItem className="flex items-start gap-2 space-y-0">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FeatureInput
|
<FeatureInput
|
||||||
value={field.value}
|
value={field.value ?? ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
placeholder="e.g. Real-time collaboration"
|
placeholder="e.g. Real-time collaboration"
|
||||||
data-testid={`input-feature-${index}`}
|
data-testid={`input-feature-${index}`}
|
||||||
|
|||||||
@@ -9,6 +9,82 @@ export interface HealthStatus {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthModeMode = typeof AuthModeMode[keyof typeof AuthModeMode];
|
||||||
|
|
||||||
|
|
||||||
|
export const AuthModeMode = {
|
||||||
|
oidc: 'oidc',
|
||||||
|
local: 'local',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface AuthMode {
|
||||||
|
mode: AuthModeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalLoginInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = typeof UserRole[keyof typeof UserRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
/** @nullable */
|
||||||
|
email?: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCreateInputRole = typeof UserCreateInputRole[keyof typeof UserCreateInputRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserCreateInputRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface UserCreateInput {
|
||||||
|
/** @minLength 2 */
|
||||||
|
username: string;
|
||||||
|
/** @minLength 6 */
|
||||||
|
password: string;
|
||||||
|
email?: string;
|
||||||
|
role?: UserCreateInputRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRoleUpdateRole = typeof UserRoleUpdateRole[keyof typeof UserRoleUpdateRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserRoleUpdateRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface UserRoleUpdate {
|
||||||
|
role: UserRoleUpdateRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: number;
|
||||||
|
entityType: string;
|
||||||
|
/** @nullable */
|
||||||
|
entityId?: number | null;
|
||||||
|
action: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
/** @nullable */
|
||||||
|
changes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Tool {
|
export interface Tool {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,6 +94,8 @@ export interface Tool {
|
|||||||
websiteUrl?: string | null;
|
websiteUrl?: string | null;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
createdBy?: string | null;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -33,6 +111,8 @@ export interface ToolWithStats {
|
|||||||
websiteUrl?: string | null;
|
websiteUrl?: string | null;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
createdBy?: string | null;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -144,6 +224,14 @@ export interface RatingDistribution {
|
|||||||
usability: ScoreBucket[];
|
usability: ScoreBucket[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthUserRole = typeof AuthUserRole[keyof typeof AuthUserRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const AuthUserRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
sub: string;
|
sub: string;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
@@ -152,6 +240,8 @@ export interface AuthUser {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
preferredUsername?: string | null;
|
preferredUsername?: string | null;
|
||||||
|
role?: AuthUserRole;
|
||||||
|
isLocal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
@@ -191,3 +281,9 @@ export type GetRatingDistributionParams = {
|
|||||||
toolId?: number;
|
toolId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ListAuditLogsParams = {
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ import type {
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AnalyticsSummary,
|
AnalyticsSummary,
|
||||||
|
AuditLog,
|
||||||
|
AuthMode,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
CategoryStats,
|
CategoryStats,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
GetRatingDistributionParams,
|
GetRatingDistributionParams,
|
||||||
GetTopToolsParams,
|
GetTopToolsParams,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
|
ListAuditLogsParams,
|
||||||
ListToolsParams,
|
ListToolsParams,
|
||||||
|
LocalLoginInput,
|
||||||
Rating,
|
Rating,
|
||||||
RatingDistribution,
|
RatingDistribution,
|
||||||
RatingInput,
|
RatingInput,
|
||||||
@@ -35,7 +39,10 @@ import type {
|
|||||||
ToolInput,
|
ToolInput,
|
||||||
ToolUpdate,
|
ToolUpdate,
|
||||||
ToolWithStats,
|
ToolWithStats,
|
||||||
TopToolEntry
|
TopToolEntry,
|
||||||
|
User,
|
||||||
|
UserCreateInput,
|
||||||
|
UserRoleUpdate
|
||||||
} from './api.schemas';
|
} from './api.schemas';
|
||||||
|
|
||||||
import { customFetch } from '../custom-fetch';
|
import { customFetch } from '../custom-fetch';
|
||||||
@@ -1127,6 +1134,154 @@ export function useListAllFeatures<TData = Awaited<ReturnType<typeof listAllFeat
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetAuthModeUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/auth/mode`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get authentication mode (oidc or local)
|
||||||
|
*/
|
||||||
|
export const getAuthMode = async ( options?: RequestInit): Promise<AuthMode> => {
|
||||||
|
|
||||||
|
return customFetch<AuthMode>(getGetAuthModeUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetAuthModeQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/api/auth/mode`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetAuthModeQueryOptions = <TData = Awaited<ReturnType<typeof getAuthMode>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getAuthMode>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetAuthModeQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAuthMode>>> = ({ signal }) => getAuthMode({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getAuthMode>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetAuthModeQueryResult = NonNullable<Awaited<ReturnType<typeof getAuthMode>>>
|
||||||
|
export type GetAuthModeQueryError = ErrorType<unknown>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get authentication mode (oidc or local)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetAuthMode<TData = Awaited<ReturnType<typeof getAuthMode>>, TError = ErrorType<unknown>>(
|
||||||
|
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getAuthMode>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getGetAuthModeQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getLocalLoginUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/auth/login`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Local username/password login
|
||||||
|
*/
|
||||||
|
export const localLogin = async (localLoginInput: LocalLoginInput, options?: RequestInit): Promise<AuthUser> => {
|
||||||
|
|
||||||
|
return customFetch<AuthUser>(getLocalLoginUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
localLoginInput,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getLocalLoginMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof localLogin>>, TError,{data: BodyType<LocalLoginInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof localLogin>>, TError,{data: BodyType<LocalLoginInput>}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['localLogin'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof localLogin>>, {data: BodyType<LocalLoginInput>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return localLogin(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type LocalLoginMutationResult = NonNullable<Awaited<ReturnType<typeof localLogin>>>
|
||||||
|
export type LocalLoginMutationBody = BodyType<LocalLoginInput>
|
||||||
|
export type LocalLoginMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Local username/password login
|
||||||
|
*/
|
||||||
|
export const useLocalLogin = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof localLogin>>, TError,{data: BodyType<LocalLoginInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof localLogin>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<LocalLoginInput>},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getLocalLoginMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
export const getGetMeUrl = () => {
|
export const getGetMeUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
@@ -1204,3 +1359,377 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListUsersUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/users`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all local users (admin only)
|
||||||
|
*/
|
||||||
|
export const listUsers = async ( options?: RequestInit): Promise<User[]> => {
|
||||||
|
|
||||||
|
return customFetch<User[]>(getListUsersUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListUsersQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/api/users`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListUsersQueryOptions = <TData = Awaited<ReturnType<typeof listUsers>>, TError = ErrorType<ErrorResponse>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listUsers>>> = ({ signal }) => listUsers({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListUsersQueryResult = NonNullable<Awaited<ReturnType<typeof listUsers>>>
|
||||||
|
export type ListUsersQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all local users (admin only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getListUsersQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getCreateUserUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/users`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Create a new local user (admin only)
|
||||||
|
*/
|
||||||
|
export const createUser = async (userCreateInput: UserCreateInput, options?: RequestInit): Promise<User> => {
|
||||||
|
|
||||||
|
return customFetch<User>(getCreateUserUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
userCreateInput,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getCreateUserMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: BodyType<UserCreateInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: BodyType<UserCreateInput>}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['createUser'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createUser>>, {data: BodyType<UserCreateInput>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return createUser(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type CreateUserMutationResult = NonNullable<Awaited<ReturnType<typeof createUser>>>
|
||||||
|
export type CreateUserMutationBody = BodyType<UserCreateInput>
|
||||||
|
export type CreateUserMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Create a new local user (admin only)
|
||||||
|
*/
|
||||||
|
export const useCreateUser = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: BodyType<UserCreateInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof createUser>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<UserCreateInput>},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getCreateUserMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUpdateUserUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/users/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update user role (admin only)
|
||||||
|
*/
|
||||||
|
export const updateUser = async (id: number,
|
||||||
|
userRoleUpdate: UserRoleUpdate, options?: RequestInit): Promise<User> => {
|
||||||
|
|
||||||
|
return customFetch<User>(getUpdateUserUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
userRoleUpdate,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getUpdateUserMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['updateUser'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateUser>>, {id: number;data: BodyType<UserRoleUpdate>}> = (props) => {
|
||||||
|
const {id,data} = props ?? {};
|
||||||
|
|
||||||
|
return updateUser(id,data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type UpdateUserMutationResult = NonNullable<Awaited<ReturnType<typeof updateUser>>>
|
||||||
|
export type UpdateUserMutationBody = BodyType<UserRoleUpdate>
|
||||||
|
export type UpdateUserMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update user role (admin only)
|
||||||
|
*/
|
||||||
|
export const useUpdateUser = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{id: number;data: BodyType<UserRoleUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof updateUser>>,
|
||||||
|
TError,
|
||||||
|
{id: number;data: BodyType<UserRoleUpdate>},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getUpdateUserMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDeleteUserUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/users/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Delete a user (admin only)
|
||||||
|
*/
|
||||||
|
export const deleteUser = async (id: number, options?: RequestInit): Promise<void> => {
|
||||||
|
|
||||||
|
return customFetch<void>(getDeleteUserUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'DELETE'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getDeleteUserMutationOptions = <TError = ErrorType<unknown>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{id: number}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['deleteUser'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteUser>>, {id: number}> = (props) => {
|
||||||
|
const {id} = props ?? {};
|
||||||
|
|
||||||
|
return deleteUser(id,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type DeleteUserMutationResult = NonNullable<Awaited<ReturnType<typeof deleteUser>>>
|
||||||
|
|
||||||
|
export type DeleteUserMutationError = ErrorType<unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Delete a user (admin only)
|
||||||
|
*/
|
||||||
|
export const useDeleteUser = <TError = ErrorType<unknown>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof deleteUser>>,
|
||||||
|
TError,
|
||||||
|
{id: number},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getDeleteUserMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
|
||||||
|
const normalizedParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
|
return stringifiedParams.length > 0 ? `/api/audit-logs?${stringifiedParams}` : `/api/audit-logs`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List audit log entries (admin only)
|
||||||
|
*/
|
||||||
|
export const listAuditLogs = async (params?: ListAuditLogsParams, options?: RequestInit): Promise<AuditLog[]> => {
|
||||||
|
|
||||||
|
return customFetch<AuditLog[]>(getListAuditLogsUrl(params),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListAuditLogsQueryKey = (params?: ListAuditLogsParams,) => {
|
||||||
|
return [
|
||||||
|
`/api/audit-logs`, ...(params ? [params] : [])
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListAuditLogsQueryOptions = <TData = Awaited<ReturnType<typeof listAuditLogs>>, TError = ErrorType<unknown>>(params?: ListAuditLogsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listAuditLogs>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListAuditLogsQueryKey(params);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAuditLogs>>> = ({ signal }) => listAuditLogs(params, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listAuditLogs>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListAuditLogsQueryResult = NonNullable<Awaited<ReturnType<typeof listAuditLogs>>>
|
||||||
|
export type ListAuditLogsQueryError = ErrorType<unknown>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List audit log entries (admin only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListAuditLogs<TData = Awaited<ReturnType<typeof listAuditLogs>>, TError = ErrorType<unknown>>(
|
||||||
|
params?: ListAuditLogsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listAuditLogs>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getListAuditLogsQueryOptions(params,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ tags:
|
|||||||
description: Tool ratings
|
description: Tool ratings
|
||||||
- name: analytics
|
- name: analytics
|
||||||
description: Analytics and aggregated statistics
|
description: Analytics and aggregated statistics
|
||||||
|
- name: auth
|
||||||
|
description: Authentication
|
||||||
|
- name: users
|
||||||
|
description: User management (admin only)
|
||||||
|
- name: audit
|
||||||
|
description: Audit log
|
||||||
paths:
|
paths:
|
||||||
/healthz:
|
/healthz:
|
||||||
get:
|
get:
|
||||||
@@ -319,6 +325,44 @@ paths:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
/auth/mode:
|
||||||
|
get:
|
||||||
|
operationId: getAuthMode
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get authentication mode (oidc or local)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Auth mode
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AuthMode"
|
||||||
|
|
||||||
|
/auth/login:
|
||||||
|
post:
|
||||||
|
operationId: localLogin
|
||||||
|
tags: [auth]
|
||||||
|
summary: Local username/password login
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LocalLoginInput"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Logged in successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AuthUser"
|
||||||
|
"401":
|
||||||
|
description: Invalid credentials
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/auth/me:
|
/auth/me:
|
||||||
get:
|
get:
|
||||||
operationId: getMe
|
operationId: getMe
|
||||||
@@ -338,6 +382,137 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/users:
|
||||||
|
get:
|
||||||
|
operationId: listUsers
|
||||||
|
tags: [users]
|
||||||
|
summary: List all local users (admin only)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: User list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/User"
|
||||||
|
"401":
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
post:
|
||||||
|
operationId: createUser
|
||||||
|
tags: [users]
|
||||||
|
summary: Create a new local user (admin only)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserCreateInput"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created user
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/User"
|
||||||
|
"400":
|
||||||
|
description: Validation error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"409":
|
||||||
|
description: Username already exists
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/users/{id}:
|
||||||
|
patch:
|
||||||
|
operationId: updateUser
|
||||||
|
tags: [users]
|
||||||
|
summary: Update user role (admin only)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserRoleUpdate"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated user
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/User"
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
delete:
|
||||||
|
operationId: deleteUser
|
||||||
|
tags: [users]
|
||||||
|
summary: Delete a user (admin only)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Deleted
|
||||||
|
|
||||||
|
/audit-logs:
|
||||||
|
get:
|
||||||
|
operationId: listAuditLogs
|
||||||
|
tags: [audit]
|
||||||
|
summary: List audit log entries (admin only)
|
||||||
|
parameters:
|
||||||
|
- name: entityType
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: entityId
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Audit log entries
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AuditLog"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
HealthStatus:
|
HealthStatus:
|
||||||
@@ -348,6 +523,86 @@ components:
|
|||||||
required:
|
required:
|
||||||
- status
|
- status
|
||||||
|
|
||||||
|
AuthMode:
|
||||||
|
type: object
|
||||||
|
required: [mode]
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [oidc, local]
|
||||||
|
|
||||||
|
LocalLoginInput:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
required: [id, username, role, createdAt]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: ["string", "null"]
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [admin, user]
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
UserCreateInput:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 6
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [admin, user]
|
||||||
|
|
||||||
|
UserRoleUpdate:
|
||||||
|
type: object
|
||||||
|
required: [role]
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [admin, user]
|
||||||
|
|
||||||
|
AuditLog:
|
||||||
|
type: object
|
||||||
|
required: [id, entityType, action, userId, username, createdAt]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
entityType:
|
||||||
|
type: string
|
||||||
|
entityId:
|
||||||
|
type: ["integer", "null"]
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
changes:
|
||||||
|
type: ["string", "null"]
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
Tool:
|
Tool:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, description, category, createdAt, updatedAt]
|
required: [id, name, description, category, createdAt, updatedAt]
|
||||||
@@ -364,6 +619,8 @@ components:
|
|||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
iconUrl:
|
iconUrl:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
createdBy:
|
||||||
|
type: ["string", "null"]
|
||||||
features:
|
features:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -395,6 +652,8 @@ components:
|
|||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
iconUrl:
|
iconUrl:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
createdBy:
|
||||||
|
type: ["string", "null"]
|
||||||
features:
|
features:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -587,6 +846,11 @@ components:
|
|||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
preferredUsername:
|
preferredUsername:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [admin, user]
|
||||||
|
isLocal:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const ListToolsResponseItem = zod.object({
|
|||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
"websiteUrl": zod.string().nullish(),
|
"websiteUrl": zod.string().nullish(),
|
||||||
"iconUrl": zod.string().nullish(),
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
"features": zod.array(zod.string()).optional(),
|
"features": zod.array(zod.string()).optional(),
|
||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
@@ -78,6 +79,7 @@ export const GetToolResponse = zod.object({
|
|||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
"websiteUrl": zod.string().nullish(),
|
"websiteUrl": zod.string().nullish(),
|
||||||
"iconUrl": zod.string().nullish(),
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
"features": zod.array(zod.string()).optional(),
|
"features": zod.array(zod.string()).optional(),
|
||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
@@ -116,6 +118,7 @@ export const UpdateToolResponse = zod.object({
|
|||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
"websiteUrl": zod.string().nullish(),
|
"websiteUrl": zod.string().nullish(),
|
||||||
"iconUrl": zod.string().nullish(),
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
"features": zod.array(zod.string()).optional(),
|
"features": zod.array(zod.string()).optional(),
|
||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
@@ -194,6 +197,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
|||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
"websiteUrl": zod.string().nullish(),
|
"websiteUrl": zod.string().nullish(),
|
||||||
"iconUrl": zod.string().nullish(),
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
"features": zod.array(zod.string()).optional(),
|
"features": zod.array(zod.string()).optional(),
|
||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
@@ -222,6 +226,7 @@ export const GetTopToolsResponseItem = zod.object({
|
|||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
"websiteUrl": zod.string().nullish(),
|
"websiteUrl": zod.string().nullish(),
|
||||||
"iconUrl": zod.string().nullish(),
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
"features": zod.array(zod.string()).optional(),
|
"features": zod.array(zod.string()).optional(),
|
||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
@@ -283,6 +288,32 @@ export const ListAllFeaturesResponseItem = zod.string()
|
|||||||
export const ListAllFeaturesResponse = zod.array(ListAllFeaturesResponseItem)
|
export const ListAllFeaturesResponse = zod.array(ListAllFeaturesResponseItem)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get authentication mode (oidc or local)
|
||||||
|
*/
|
||||||
|
export const GetAuthModeResponse = zod.object({
|
||||||
|
"mode": zod.enum(['oidc', 'local'])
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Local username/password login
|
||||||
|
*/
|
||||||
|
export const LocalLoginBody = zod.object({
|
||||||
|
"username": zod.string(),
|
||||||
|
"password": zod.string()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LocalLoginResponse = zod.object({
|
||||||
|
"sub": zod.string(),
|
||||||
|
"email": zod.string().nullish(),
|
||||||
|
"name": zod.string().nullish(),
|
||||||
|
"preferredUsername": zod.string().nullish(),
|
||||||
|
"role": zod.enum(['admin', 'user']).optional(),
|
||||||
|
"isLocal": zod.boolean().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get current authenticated user
|
* @summary Get current authenticated user
|
||||||
*/
|
*/
|
||||||
@@ -290,7 +321,89 @@ export const GetMeResponse = zod.object({
|
|||||||
"sub": zod.string(),
|
"sub": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"name": zod.string().nullish(),
|
"name": zod.string().nullish(),
|
||||||
"preferredUsername": zod.string().nullish()
|
"preferredUsername": zod.string().nullish(),
|
||||||
|
"role": zod.enum(['admin', 'user']).optional(),
|
||||||
|
"isLocal": zod.boolean().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List all local users (admin only)
|
||||||
|
*/
|
||||||
|
export const ListUsersResponseItem = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"username": zod.string(),
|
||||||
|
"email": zod.string().nullish(),
|
||||||
|
"role": zod.enum(['admin', 'user']),
|
||||||
|
"createdAt": zod.coerce.date()
|
||||||
|
})
|
||||||
|
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Create a new local user (admin only)
|
||||||
|
*/
|
||||||
|
export const createUserBodyUsernameMin = 2;
|
||||||
|
|
||||||
|
export const createUserBodyPasswordMin = 6;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const CreateUserBody = zod.object({
|
||||||
|
"username": zod.string().min(createUserBodyUsernameMin),
|
||||||
|
"password": zod.string().min(createUserBodyPasswordMin),
|
||||||
|
"email": zod.string().optional(),
|
||||||
|
"role": zod.enum(['admin', 'user']).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update user role (admin only)
|
||||||
|
*/
|
||||||
|
export const UpdateUserParams = zod.object({
|
||||||
|
"id": zod.coerce.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const UpdateUserBody = zod.object({
|
||||||
|
"role": zod.enum(['admin', 'user'])
|
||||||
|
})
|
||||||
|
|
||||||
|
export const UpdateUserResponse = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"username": zod.string(),
|
||||||
|
"email": zod.string().nullish(),
|
||||||
|
"role": zod.enum(['admin', 'user']),
|
||||||
|
"createdAt": zod.coerce.date()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Delete a user (admin only)
|
||||||
|
*/
|
||||||
|
export const DeleteUserParams = zod.object({
|
||||||
|
"id": zod.coerce.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary List audit log entries (admin only)
|
||||||
|
*/
|
||||||
|
export const ListAuditLogsQueryParams = zod.object({
|
||||||
|
"entityType": zod.coerce.string().optional(),
|
||||||
|
"entityId": zod.coerce.number().optional(),
|
||||||
|
"limit": zod.coerce.number().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ListAuditLogsResponseItem = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"entityType": zod.string(),
|
||||||
|
"entityId": zod.number().nullish(),
|
||||||
|
"action": zod.string(),
|
||||||
|
"userId": zod.string(),
|
||||||
|
"username": zod.string(),
|
||||||
|
"changes": zod.string().nullish(),
|
||||||
|
"createdAt": zod.coerce.date()
|
||||||
|
})
|
||||||
|
export const ListAuditLogsResponse = zod.array(ListAuditLogsResponseItem)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 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 AuditLog {
|
||||||
|
id: number;
|
||||||
|
entityType: string;
|
||||||
|
/** @nullable */
|
||||||
|
entityId?: number | null;
|
||||||
|
action: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
/** @nullable */
|
||||||
|
changes?: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { AuthModeMode } from './authModeMode';
|
||||||
|
|
||||||
|
export interface AuthMode {
|
||||||
|
mode: AuthModeMode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 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 type AuthModeMode = typeof AuthModeMode[keyof typeof AuthModeMode];
|
||||||
|
|
||||||
|
|
||||||
|
export const AuthModeMode = {
|
||||||
|
oidc: 'oidc',
|
||||||
|
local: 'local',
|
||||||
|
} as const;
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { AuthUserRole } from './authUserRole';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -14,4 +15,6 @@ export interface AuthUser {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
preferredUsername?: string | null;
|
preferredUsername?: string | null;
|
||||||
|
role?: AuthUserRole;
|
||||||
|
isLocal?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 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 type AuthUserRole = typeof AuthUserRole[keyof typeof AuthUserRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const AuthUserRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
@@ -7,15 +7,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './analyticsSummary';
|
export * from './analyticsSummary';
|
||||||
|
export * from './auditLog';
|
||||||
|
export * from './authMode';
|
||||||
|
export * from './authModeMode';
|
||||||
export * from './authUser';
|
export * from './authUser';
|
||||||
|
export * from './authUserRole';
|
||||||
export * from './categoryStats';
|
export * from './categoryStats';
|
||||||
export * from './errorResponse';
|
export * from './errorResponse';
|
||||||
export * from './getRatingDistributionParams';
|
export * from './getRatingDistributionParams';
|
||||||
export * from './getTopToolsMetric';
|
export * from './getTopToolsMetric';
|
||||||
export * from './getTopToolsParams';
|
export * from './getTopToolsParams';
|
||||||
export * from './healthStatus';
|
export * from './healthStatus';
|
||||||
|
export * from './listAuditLogsParams';
|
||||||
export * from './listToolsParams';
|
export * from './listToolsParams';
|
||||||
export * from './listToolsSort';
|
export * from './listToolsSort';
|
||||||
|
export * from './localLoginInput';
|
||||||
export * from './rating';
|
export * from './rating';
|
||||||
export * from './ratingDistribution';
|
export * from './ratingDistribution';
|
||||||
export * from './ratingInput';
|
export * from './ratingInput';
|
||||||
@@ -25,3 +31,9 @@ export * from './toolInput';
|
|||||||
export * from './toolUpdate';
|
export * from './toolUpdate';
|
||||||
export * from './toolWithStats';
|
export * from './toolWithStats';
|
||||||
export * from './topToolEntry';
|
export * from './topToolEntry';
|
||||||
|
export * from './user';
|
||||||
|
export * from './userCreateInput';
|
||||||
|
export * from './userCreateInputRole';
|
||||||
|
export * from './userRole';
|
||||||
|
export * from './userRoleUpdate';
|
||||||
|
export * from './userRoleUpdateRole';
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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 type ListAuditLogsParams = {
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 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 LocalLoginInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ export interface Tool {
|
|||||||
websiteUrl?: string | null;
|
websiteUrl?: string | null;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
createdBy?: string | null;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface ToolWithStats {
|
|||||||
websiteUrl?: string | null;
|
websiteUrl?: string | null;
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
createdBy?: string | null;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -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
|
||||||
|
*/
|
||||||
|
import type { UserRole } from './userRole';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
/** @nullable */
|
||||||
|
email?: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
*/
|
||||||
|
import type { UserCreateInputRole } from './userCreateInputRole';
|
||||||
|
|
||||||
|
export interface UserCreateInput {
|
||||||
|
/** @minLength 2 */
|
||||||
|
username: string;
|
||||||
|
/** @minLength 6 */
|
||||||
|
password: string;
|
||||||
|
email?: string;
|
||||||
|
role?: UserCreateInputRole;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 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 type UserCreateInputRole = typeof UserCreateInputRole[keyof typeof UserCreateInputRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserCreateInputRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 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 type UserRole = typeof UserRole[keyof typeof UserRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { UserRoleUpdateRole } from './userRoleUpdateRole';
|
||||||
|
|
||||||
|
export interface UserRoleUpdate {
|
||||||
|
role: UserRoleUpdateRole;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 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 type UserRoleUpdateRole = typeof UserRoleUpdateRole[keyof typeof UserRoleUpdateRole];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserRoleUpdateRole = {
|
||||||
|
admin: 'admin',
|
||||||
|
user: 'user',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { pgTable, serial, timestamp, text, integer } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const auditLogsTable = pgTable("audit_logs", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
entityId: integer("entity_id"),
|
||||||
|
action: text("action").notNull(),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
username: text("username").notNull(),
|
||||||
|
changes: text("changes"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuditLog = typeof auditLogsTable.$inferSelect;
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from "./tools";
|
export * from "./tools";
|
||||||
export * from "./ratings";
|
export * from "./ratings";
|
||||||
|
export * from "./users";
|
||||||
|
export * from "./audit-logs";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const toolsTable = pgTable("tools", {
|
|||||||
category: text("category").notNull(),
|
category: text("category").notNull(),
|
||||||
websiteUrl: text("website_url"),
|
websiteUrl: text("website_url"),
|
||||||
iconUrl: text("icon_url"),
|
iconUrl: text("icon_url"),
|
||||||
|
createdBy: text("created_by"),
|
||||||
features: text("features").array().notNull().default([]),
|
features: text("features").array().notNull().default([]),
|
||||||
tags: text("tags").array().notNull().default([]),
|
tags: text("tags").array().notNull().default([]),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { pgTable, text, serial, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const usersTable = pgTable("users", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
username: text("username").notNull().unique(),
|
||||||
|
passwordHash: text("password_hash").notNull(),
|
||||||
|
email: text("email"),
|
||||||
|
role: text("role").notNull().default("user"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const insertUserSchema = createInsertSchema(usersTable).omit({ id: true, createdAt: true });
|
||||||
|
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||||
|
export type LocalUser = typeof usersTable.$inferSelect;
|
||||||
Generated
+20
@@ -173,6 +173,9 @@ importers:
|
|||||||
'@workspace/db':
|
'@workspace/db':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../lib/db
|
version: link:../../lib/db
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3
|
||||||
connect-pg-simple:
|
connect-pg-simple:
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
@@ -201,6 +204,9 @@ importers:
|
|||||||
specifier: ^10.5.0
|
specifier: ^10.5.0
|
||||||
version: 10.5.0
|
version: 10.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/bcryptjs':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
'@types/connect-pg-simple':
|
'@types/connect-pg-simple':
|
||||||
specifier: ^7.0.3
|
specifier: ^7.0.3
|
||||||
version: 7.0.3
|
version: 7.0.3
|
||||||
@@ -1641,6 +1647,10 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/bcryptjs@3.0.0':
|
||||||
|
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
|
||||||
|
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||||
|
|
||||||
@@ -1789,6 +1799,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bcryptjs@3.0.3:
|
||||||
|
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4382,6 +4396,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@types/bcryptjs@3.0.0':
|
||||||
|
dependencies:
|
||||||
|
bcryptjs: 3.0.3
|
||||||
|
|
||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/connect': 3.4.38
|
'@types/connect': 3.4.38
|
||||||
@@ -4537,6 +4555,8 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.28: {}
|
baseline-browser-mapping@2.10.28: {}
|
||||||
|
|
||||||
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
body-parser@2.2.2:
|
body-parser@2.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
|
|||||||
Reference in New Issue
Block a user