Merged changes from qt0ebghx/main
Replit-Task-Id: e786be21-972b-4d23-bbe7-9eb4ae617f7b
This commit is contained in:
parent
e54b0528be
commit
4a7607d3a5
34 changed files with 1573 additions and 90 deletions
|
|
@ -5,3 +5,4 @@
|
|||
- [Testing api-server from shell](api-server-local-curl.md) — external `$REPLIT_DEV_DOMAIN/api` curl returns HTTP 000; curl `http://localhost:<PORT>/api` instead (port from workflow log).
|
||||
- [Stale codegen & unapplied migrations](skillguard-stale-codegen-and-migrations.md) — "field already in API" tasks: dev/test DB + lib `dist/*.d.ts` lag; run drizzle push + `tsc -b` the lib.
|
||||
- [Mocking fetch in api-server route tests](api-server-fetch-mocking-in-tests.md) — route tests run app in-process; delegate localhost requests to real fetch, only synthesize upstream; filter spy calls by URL.
|
||||
- [Clerk shadcn theme + Tailwind v4](clerk-shadcn-theme-tailwind.md) — Clerk shadcn.css needs `optimize:false` + explicit `@layer` order or sign-in/up widgets render unstyled.
|
||||
|
|
|
|||
13
.agents/memory/clerk-shadcn-theme-tailwind.md
Normal file
13
.agents/memory/clerk-shadcn-theme-tailwind.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
name: Clerk shadcn theme + Tailwind v4
|
||||
description: Why Clerk's shadcn.css theme needs Tailwind optimize:false and a layer order in a Vite app
|
||||
---
|
||||
|
||||
When wiring Clerk components to match a shadcn/ui design (via `@import "@clerk/themes/shadcn.css"`), two non-obvious settings are required in a Tailwind v4 + Vite app:
|
||||
|
||||
- `index.css` must declare the cascade layer order explicitly, e.g. `@layer theme, base, clerk, components, utilities;` before importing the Clerk theme, so Clerk styles land in the right layer and don't get overridden by (or override) app styles.
|
||||
- `vite.config.ts` must set `tailwindcss({ optimize: false })`.
|
||||
|
||||
**Why:** Tailwind v4's CSS optimizer/minifier prunes the Clerk theme's selectors (it can't see Clerk's runtime-generated class usage), so with optimization on the Clerk sign-in/sign-up widgets render unstyled. Disabling optimize keeps the imported theme intact.
|
||||
|
||||
**How to apply:** Any artifact that themes Clerk widgets through the shadcn theme import. Symptom if missing: Clerk `<SignIn/>`/`<SignUp/>` appear unstyled or mismatched despite the import being present.
|
||||
4
.replit
4
.replit
|
|
@ -47,6 +47,10 @@ externalPort = 80
|
|||
localPort = 8081
|
||||
externalPort = 8081
|
||||
|
||||
[[ports]]
|
||||
localPort = 8082
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 20892
|
||||
externalPort = 3000
|
||||
|
|
|
|||
|
|
@ -11,13 +11,17 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/express": "^2.1.23",
|
||||
"@clerk/shared": "^4.15.0",
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"drizzle-orm": "catalog:",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"fflate": "^0.8.3",
|
||||
"http-proxy-middleware": "^4.1.0",
|
||||
"pino": "^9.14.0",
|
||||
"pino-http": "^10.5.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import express, { type Express } from "express";
|
||||
import cors from "cors";
|
||||
import pinoHttp from "pino-http";
|
||||
import { clerkMiddleware } from "@clerk/express";
|
||||
import { publishableKeyFromHost } from "@clerk/shared/keys";
|
||||
import {
|
||||
CLERK_PROXY_PATH,
|
||||
clerkProxyMiddleware,
|
||||
getClerkProxyHost,
|
||||
} from "./middlewares/clerkProxyMiddleware";
|
||||
import router from "./routes";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
// Trust the Replit proxy so req.ip reflects the client for rate limiting.
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
|
|
@ -25,10 +35,27 @@ app.use(
|
|||
},
|
||||
}),
|
||||
);
|
||||
app.use(cors());
|
||||
|
||||
// Clerk Frontend API proxy must be mounted before the body parsers since it
|
||||
// streams raw bytes. Active in production only.
|
||||
app.use(CLERK_PROXY_PATH, clerkProxyMiddleware());
|
||||
|
||||
app.use(cors({ credentials: true, origin: true }));
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "25mb" }));
|
||||
|
||||
// Resolve the publishable key from the incoming request host so the same
|
||||
// server can serve multiple Clerk custom domains. Falls back to
|
||||
// CLERK_PUBLISHABLE_KEY when the host doesn't map to a custom domain.
|
||||
app.use(
|
||||
clerkMiddleware((req) => ({
|
||||
publishableKey: publishableKeyFromHost(
|
||||
getClerkProxyHost(req) ?? "",
|
||||
process.env.CLERK_PUBLISHABLE_KEY,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
app.use("/api", router);
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
75
artifacts/api-server/src/middlewares/auth.ts
Normal file
75
artifacts/api-server/src/middlewares/auth.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
import { getAuth, clerkClient } from "@clerk/express";
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
/**
|
||||
* Admin access is restricted to an allowlist of email addresses configured via
|
||||
* the ADMIN_EMAILS environment variable (comma-separated, case-insensitive).
|
||||
* Signing in alone never grants admin access — the email must be on the list.
|
||||
*/
|
||||
export function getAdminAllowlist(): string[] {
|
||||
return (process.env.ADMIN_EMAILS ?? "")
|
||||
.split(",")
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export type AuthInfo = {
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the caller's authentication state without throwing. Anonymous
|
||||
* visitors short-circuit before any Clerk API call so public endpoints stay
|
||||
* fast; only signed-in users incur a user lookup to read their email.
|
||||
*/
|
||||
export async function resolveAuth(req: Request): Promise<AuthInfo> {
|
||||
const auth = getAuth(req);
|
||||
const userId = auth?.userId ?? null;
|
||||
if (!userId) return { userId: null, email: null, isAdmin: false };
|
||||
|
||||
let email: string | null = null;
|
||||
try {
|
||||
const user = await clerkClient.users.getUser(userId);
|
||||
email =
|
||||
user.primaryEmailAddress?.emailAddress ??
|
||||
user.emailAddresses[0]?.emailAddress ??
|
||||
null;
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Clerk-Benutzer konnte nicht geladen werden");
|
||||
email = null;
|
||||
}
|
||||
|
||||
const allowlist = getAdminAllowlist();
|
||||
const isAdmin = !!email && allowlist.includes(email.toLowerCase());
|
||||
return { userId, email, isAdmin };
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
const info = await resolveAuth(req);
|
||||
if (!info.userId) {
|
||||
res.status(401).json({ error: "Nicht angemeldet." });
|
||||
return;
|
||||
}
|
||||
if (!info.isAdmin) {
|
||||
res.status(403).json({ error: "Kein Administratorzugriff." });
|
||||
return;
|
||||
}
|
||||
req.auth = info;
|
||||
next();
|
||||
}
|
||||
91
artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts
Normal file
91
artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Clerk Frontend API Proxy Middleware
|
||||
*
|
||||
* Proxies Clerk Frontend API requests through your domain, enabling Clerk
|
||||
* authentication on custom domains and .replit.app deployments without
|
||||
* requiring CNAME DNS configuration.
|
||||
*
|
||||
* AUTH CONFIGURATION: To manage users, enable/disable login providers
|
||||
* (Google, GitHub, etc.), change app branding, or configure OAuth credentials,
|
||||
* use the Auth pane in the workspace toolbar. There is no external Clerk
|
||||
* dashboard — all auth configuration is done through the Auth pane.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Only active in production (Clerk proxying doesn't work for dev instances)
|
||||
* - Must be mounted BEFORE express.json() middleware
|
||||
*
|
||||
* Usage in app.ts:
|
||||
* import { CLERK_PROXY_PATH, clerkProxyMiddleware } from "./middlewares/clerkProxyMiddleware";
|
||||
* app.use(CLERK_PROXY_PATH, clerkProxyMiddleware());
|
||||
*/
|
||||
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { IncomingHttpHeaders } from "http";
|
||||
|
||||
const CLERK_FAPI = "https://frontend-api.clerk.dev";
|
||||
export const CLERK_PROXY_PATH = "/api/__clerk";
|
||||
|
||||
/**
|
||||
* Returns the first effective public hostname for the given request,
|
||||
* preferring x-forwarded-host over the Host header so callers behind a
|
||||
* proxy see the original client-facing host.
|
||||
*
|
||||
* x-forwarded-host can take three shapes:
|
||||
* - undefined (no proxy involved)
|
||||
* - a single string (one proxy hop)
|
||||
* - a comma-delimited string when an upstream appended rather than
|
||||
* replaced the header (Node folds duplicate headers this way), or a
|
||||
* string[] in some Express typings
|
||||
* In the multi-value case, the leftmost value is the original client-
|
||||
* facing host. Take that one in all forms. Exported so that app.ts
|
||||
* (clerkMiddleware callback) and this proxy middleware agree on which
|
||||
* hostname is canonical — otherwise multi-domain/custom-domain flows
|
||||
* break.
|
||||
*/
|
||||
export function getClerkProxyHost(req: {
|
||||
headers: IncomingHttpHeaders;
|
||||
}): string | undefined {
|
||||
const forwarded = req.headers["x-forwarded-host"];
|
||||
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||
const firstHop = raw?.split(",")[0]?.trim();
|
||||
return firstHop || req.headers.host?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function clerkProxyMiddleware(): RequestHandler {
|
||||
// Only run proxy in production — Clerk proxying doesn't work for dev instances
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
const secretKey = process.env.CLERK_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
return createProxyMiddleware({
|
||||
target: CLERK_FAPI,
|
||||
changeOrigin: true,
|
||||
pathRewrite: (path: string) =>
|
||||
path.replace(new RegExp(`^${CLERK_PROXY_PATH}`), ""),
|
||||
on: {
|
||||
proxyReq: (proxyReq, req) => {
|
||||
const protocol = req.headers["x-forwarded-proto"] || "https";
|
||||
const host = getClerkProxyHost(req) || "";
|
||||
const proxyUrl = `${protocol}://${host}${CLERK_PROXY_PATH}`;
|
||||
|
||||
proxyReq.setHeader("Clerk-Proxy-Url", proxyUrl);
|
||||
proxyReq.setHeader("Clerk-Secret-Key", secretKey);
|
||||
|
||||
const xff = req.headers["x-forwarded-for"];
|
||||
const clientIp =
|
||||
(Array.isArray(xff) ? xff[0] : xff)?.split(",")[0]?.trim() ||
|
||||
req.socket?.remoteAddress ||
|
||||
"";
|
||||
if (clientIp) {
|
||||
proxyReq.setHeader("X-Forwarded-For", clientIp);
|
||||
}
|
||||
},
|
||||
},
|
||||
}) as RequestHandler;
|
||||
}
|
||||
18
artifacts/api-server/src/routes/auth.ts
Normal file
18
artifacts/api-server/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Router, type IRouter } from "express";
|
||||
import { GetMeResponse } from "@workspace/api-zod";
|
||||
import { resolveAuth } from "../middlewares/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.get("/me", async (req, res) => {
|
||||
const info = await resolveAuth(req);
|
||||
res.json(
|
||||
GetMeResponse.parse({
|
||||
authenticated: !!info.userId,
|
||||
isAdmin: info.isAdmin,
|
||||
email: info.email,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,18 +1,32 @@
|
|||
import { Router, type IRouter } from "express";
|
||||
import healthRouter from "./health";
|
||||
import authRouter from "./auth";
|
||||
import dashboardRouter from "./dashboard";
|
||||
import scansRouter from "./scans";
|
||||
import providersRouter from "./providers";
|
||||
import promptsRouter from "./prompts";
|
||||
import rulesRouter from "./rules";
|
||||
import { requireAdmin } from "../middlewares/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Public endpoints (no login required).
|
||||
router.use(healthRouter);
|
||||
router.use(dashboardRouter);
|
||||
router.use(authRouter);
|
||||
// Scans router owns its own auth: public list/report/download/create, but
|
||||
// admin-only delete and moderation. Rules expose a public GET with an
|
||||
// admin-only PATCH (enforced inside the router).
|
||||
router.use(scansRouter);
|
||||
router.use(providersRouter);
|
||||
router.use(promptsRouter);
|
||||
router.use(rulesRouter);
|
||||
|
||||
// Admin-only resources: the entire /providers, /prompts and /dashboard
|
||||
// surfaces require an allowlisted admin. Path-scoped so public routes above
|
||||
// are never gated.
|
||||
router.use("/providers", requireAdmin);
|
||||
router.use("/prompts", requireAdmin);
|
||||
router.use("/dashboard", requireAdmin);
|
||||
router.use(providersRouter);
|
||||
router.use(promptsRouter);
|
||||
router.use(dashboardRouter);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,24 @@ import {
|
|||
} from "vitest";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
// The /providers routes are admin-gated. These tests exercise the route logic
|
||||
// itself, not the Clerk allowlist, so we stub the auth middleware to grant
|
||||
// admin access. Auth enforcement is covered separately.
|
||||
vi.mock("../middlewares/auth", () => ({
|
||||
getAdminAllowlist: () => ["admin@test.local"],
|
||||
resolveAuth: async () => ({
|
||||
userId: "test-admin",
|
||||
email: "admin@test.local",
|
||||
isAdmin: true,
|
||||
}),
|
||||
requireAdmin: (
|
||||
_req: unknown,
|
||||
_res: unknown,
|
||||
next: () => void,
|
||||
) => next(),
|
||||
}));
|
||||
|
||||
import app from "../app";
|
||||
import { db, pool, aiProvidersTable } from "@workspace/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
UpdateRuleBody,
|
||||
UpdateRuleResponse,
|
||||
} from "@workspace/api-zod";
|
||||
import { requireAdmin } from "../middlewares/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ router.get("/rules", async (_req, res) => {
|
|||
res.json(ListRulesResponse.parse(rows.map(serializeRule)));
|
||||
});
|
||||
|
||||
router.patch("/rules/:id", async (req, res) => {
|
||||
router.patch("/rules/:id", requireAdmin, async (req, res) => {
|
||||
const params = UpdateRuleParams.safeParse(req.params);
|
||||
if (!params.success) return res.status(400).json({ message: "Ungültige ID" });
|
||||
const parsed = UpdateRuleBody.safeParse(req.body);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
type Prompt,
|
||||
} from "@workspace/db";
|
||||
import { eq, desc, count } from "drizzle-orm";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
import {
|
||||
ListScansResponse,
|
||||
CreateScanBody,
|
||||
|
|
@ -22,7 +24,11 @@ import {
|
|||
CompareScansParams,
|
||||
CompareScansResponse,
|
||||
GetScanLineageResponse,
|
||||
ModerateScanParams,
|
||||
ModerateScanBody,
|
||||
ModerateScanResponse,
|
||||
} from "@workspace/api-zod";
|
||||
import { resolveAuth, requireAdmin } from "../middlewares/auth";
|
||||
import {
|
||||
parseUpload,
|
||||
parseText,
|
||||
|
|
@ -56,10 +62,23 @@ export function serializeScan(scan: Scan) {
|
|||
relation: scan.relation,
|
||||
similarity: scan.similarity,
|
||||
comparedScanId: scan.comparedScanId,
|
||||
hidden: scan.hidden,
|
||||
createdAt: scan.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Public scan creation is rate-limited per client to curb abuse of the open
|
||||
// upload/test endpoints. Admin and read endpoints are unaffected.
|
||||
const scanRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
message: "Zu viele Scans in kurzer Zeit. Bitte später erneut versuchen.",
|
||||
},
|
||||
});
|
||||
|
||||
function serializeFile(f: ScanFile) {
|
||||
return {
|
||||
path: f.path,
|
||||
|
|
@ -413,15 +432,19 @@ async function persistScan(
|
|||
return { scan, files: insertedFiles, findings: insertedFindings };
|
||||
}
|
||||
|
||||
router.get("/scans", async (_req, res) => {
|
||||
router.get("/scans", async (req, res) => {
|
||||
// Public visitors only see the released catalog; admins also see hidden scans
|
||||
// so they can manage moderation.
|
||||
const info = await resolveAuth(req);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scansTable)
|
||||
.where(info.isAdmin ? undefined : eq(scansTable.hidden, false))
|
||||
.orderBy(desc(scansTable.createdAt));
|
||||
res.json(ListScansResponse.parse(rows.map(serializeScan)));
|
||||
});
|
||||
|
||||
router.post("/scans", async (req, res) => {
|
||||
router.post("/scans", scanRateLimiter, async (req, res) => {
|
||||
const parsed = CreateScanBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res
|
||||
|
|
@ -453,7 +476,7 @@ router.post("/scans", async (req, res) => {
|
|||
const STREAM_PACING_MS = 80;
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
router.post("/scans/stream", async (req, res) => {
|
||||
router.post("/scans/stream", scanRateLimiter, async (req, res) => {
|
||||
const parsed = CreateScanBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res
|
||||
|
|
@ -544,6 +567,13 @@ router.get("/scans/:id", async (req, res) => {
|
|||
.where(eq(scansTable.id, params.data.id));
|
||||
if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" });
|
||||
|
||||
// Hidden scans are invisible to the public; only admins can open the report.
|
||||
if (scan.hidden) {
|
||||
const info = await resolveAuth(req);
|
||||
if (!info.isAdmin)
|
||||
return res.status(404).json({ message: "Scan nicht gefunden" });
|
||||
}
|
||||
|
||||
const files = await db
|
||||
.select()
|
||||
.from(scanFilesTable)
|
||||
|
|
@ -557,6 +587,87 @@ router.get("/scans/:id", async (req, res) => {
|
|||
return res.json(GetScanResponse.parse(await buildScanDetail(scan, files, findings)));
|
||||
});
|
||||
|
||||
// Public download of a skill that PASSED. Bundles the stored text files back
|
||||
// into a ZIP. Binary files were never persisted, so they are omitted. Blocked
|
||||
// for non-pass verdicts and for hidden scans (unless the caller is an admin).
|
||||
function safeFilename(name: string): string {
|
||||
const cleaned = name
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
return cleaned || "skill";
|
||||
}
|
||||
|
||||
router.get("/scans/:id/download", async (req, res) => {
|
||||
const params = GetScanParams.safeParse(req.params);
|
||||
if (!params.success)
|
||||
return res.status(400).json({ message: "Ungültige ID" });
|
||||
|
||||
const [scan] = await db
|
||||
.select()
|
||||
.from(scansTable)
|
||||
.where(eq(scansTable.id, params.data.id));
|
||||
if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" });
|
||||
|
||||
if (scan.hidden) {
|
||||
const info = await resolveAuth(req);
|
||||
if (!info.isAdmin)
|
||||
return res.status(404).json({ message: "Scan nicht gefunden" });
|
||||
}
|
||||
|
||||
if (scan.verdict !== "pass") {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Nur Skills mit dem Ergebnis „Bestanden“ können heruntergeladen werden.",
|
||||
});
|
||||
}
|
||||
|
||||
const files = await db
|
||||
.select()
|
||||
.from(scanFilesTable)
|
||||
.where(eq(scanFilesTable.scanId, scan.id));
|
||||
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
for (const f of files) {
|
||||
if (f.content === null) continue; // binary content was not stored
|
||||
entries[f.path] = strToU8(f.content);
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length === 0) {
|
||||
return res.status(404).json({
|
||||
message: "Für dieses Skill sind keine herunterladbaren Dateien gespeichert.",
|
||||
});
|
||||
}
|
||||
|
||||
const zipped = zipSync(entries, { level: 6 });
|
||||
const filename = `${safeFilename(scan.name)}.zip`;
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename}"`,
|
||||
);
|
||||
return res.send(Buffer.from(zipped));
|
||||
});
|
||||
|
||||
router.patch("/scans/:id", requireAdmin, async (req, res) => {
|
||||
const params = ModerateScanParams.safeParse(req.params);
|
||||
if (!params.success)
|
||||
return res.status(400).json({ message: "Ungültige ID" });
|
||||
const parsed = ModerateScanBody.safeParse(req.body);
|
||||
if (!parsed.success)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "Ungültige Eingabe", details: parsed.error.issues });
|
||||
|
||||
const [updated] = await db
|
||||
.update(scansTable)
|
||||
.set({ hidden: parsed.data.hidden })
|
||||
.where(eq(scansTable.id, params.data.id))
|
||||
.returning();
|
||||
if (!updated) return res.status(404).json({ message: "Scan nicht gefunden" });
|
||||
return res.json(ModerateScanResponse.parse(serializeScan(updated)));
|
||||
});
|
||||
|
||||
router.get("/scans/:id/compare/:otherId", async (req, res) => {
|
||||
const params = CompareScansParams.safeParse(req.params);
|
||||
if (!params.success)
|
||||
|
|
@ -729,7 +840,7 @@ router.get("/scans/:id/lineage", async (req, res) => {
|
|||
return res.json(GetScanLineageResponse.parse(entries));
|
||||
});
|
||||
|
||||
router.delete("/scans/:id", async (req, res) => {
|
||||
router.delete("/scans/:id", requireAdmin, async (req, res) => {
|
||||
const params = DeleteScanParams.safeParse(req.params);
|
||||
if (!params.success)
|
||||
return res.status(400).json({ message: "Ungültige ID" });
|
||||
|
|
|
|||
|
|
@ -73,5 +73,9 @@
|
|||
"vite": "catalog:",
|
||||
"wouter": "^3.3.5",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/react": "^6.7.3",
|
||||
"@clerk/themes": "^2.4.57"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
artifacts/skillguard/public/logo.svg
Normal file
5
artifacts/skillguard/public/logo.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="160" height="40" viewBox="0 0 160 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 4L33 9V19C33 27.5 27.4 33.6 20 36C12.6 33.6 7 27.5 7 19V9L20 4Z" fill="hsl(215 25% 27%)"/>
|
||||
<path d="M14.5 19.5L18.5 23.5L26 16" stroke="white" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="42" y="26" font-family="Inter, sans-serif" font-size="19" font-weight="700" fill="hsl(222 47% 11%)">SkillGuard</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 71 KiB |
|
|
@ -1,12 +1,17 @@
|
|||
import { Switch, Route, Router as WouterRouter } from "wouter";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ClerkProvider, SignIn, SignUp, useClerk } from "@clerk/react";
|
||||
import { publishableKeyFromHost } from "@clerk/react/internal";
|
||||
import { shadcn } from "@clerk/themes";
|
||||
import { Switch, Route, useLocation, Router as WouterRouter } from "wouter";
|
||||
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { AppLayout } from "@/components/layout";
|
||||
import { PublicLayout } from "@/components/public-layout";
|
||||
import { AppLayout } from "@/components/layout";
|
||||
import { RequireAdmin } from "@/components/require-admin";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
import Landing from "@/pages/landing";
|
||||
import Catalog from "@/pages/catalog";
|
||||
import Dashboard from "@/pages/dashboard";
|
||||
import ScanForm from "@/pages/scan-form";
|
||||
import ScanReport from "@/pages/scan-report";
|
||||
|
|
@ -16,45 +21,223 @@ import Admin from "@/pages/admin";
|
|||
import Impressum from "@/pages/impressum";
|
||||
import Haftungsausschluss from "@/pages/haftungsausschluss";
|
||||
|
||||
// REQUIRED — copy verbatim. Resolves the key from window.location.hostname so the
|
||||
// same build serves multiple Clerk custom domains.
|
||||
const clerkPubKey = publishableKeyFromHost(
|
||||
window.location.hostname,
|
||||
import.meta.env.VITE_CLERK_PUBLISHABLE_KEY,
|
||||
);
|
||||
|
||||
// REQUIRED — copy verbatim. Empty in dev, auto-set in prod.
|
||||
const clerkProxyUrl = import.meta.env.VITE_CLERK_PROXY_URL;
|
||||
|
||||
const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
// Clerk passes full paths to routerPush/routerReplace, but wouter's
|
||||
// setLocation prepends the base — strip it to avoid doubling.
|
||||
function stripBase(path: string): string {
|
||||
return basePath && path.startsWith(basePath) ? path.slice(basePath.length) || "/" : path;
|
||||
}
|
||||
|
||||
if (!clerkPubKey) {
|
||||
throw new Error("Missing VITE_CLERK_PUBLISHABLE_KEY in .env file");
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function Router() {
|
||||
const clerkAppearance = {
|
||||
theme: shadcn,
|
||||
cssLayerName: "clerk",
|
||||
options: {
|
||||
logoPlacement: "inside" as const,
|
||||
logoLinkUrl: basePath || "/",
|
||||
logoImageUrl: `${window.location.origin}${basePath}/logo.svg`,
|
||||
},
|
||||
variables: {
|
||||
colorPrimary: "hsl(215, 25%, 27%)",
|
||||
colorForeground: "hsl(222, 47%, 11%)",
|
||||
colorMutedForeground: "hsl(215, 16%, 47%)",
|
||||
colorDanger: "hsl(0, 84%, 60%)",
|
||||
colorBackground: "hsl(0, 0%, 100%)",
|
||||
colorInput: "hsl(0, 0%, 100%)",
|
||||
colorInputForeground: "hsl(222, 47%, 11%)",
|
||||
colorNeutral: "hsl(214, 32%, 91%)",
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
borderRadius: "0.5rem",
|
||||
},
|
||||
elements: {
|
||||
rootBox: "w-full flex justify-center",
|
||||
cardBox: "bg-white rounded-2xl w-[440px] max-w-full overflow-hidden shadow-lg border border-slate-200",
|
||||
card: "!shadow-none !border-0 !bg-transparent !rounded-none",
|
||||
footer: "!shadow-none !border-0 !bg-transparent !rounded-none",
|
||||
headerTitle: "text-slate-900 font-bold",
|
||||
headerSubtitle: "text-slate-500",
|
||||
socialButtonsBlockButtonText: "text-slate-700 font-medium",
|
||||
formFieldLabel: "text-slate-700 font-medium",
|
||||
footerActionLink: "text-slate-800 font-semibold hover:text-slate-900",
|
||||
footerActionText: "text-slate-500",
|
||||
dividerText: "text-slate-400",
|
||||
identityPreviewEditButton: "text-slate-700",
|
||||
formFieldSuccessText: "text-emerald-600",
|
||||
alertText: "text-slate-700",
|
||||
logoBox: "h-9",
|
||||
logoImage: "h-9 w-auto",
|
||||
socialButtonsBlockButton: "border border-slate-200 hover:bg-slate-50",
|
||||
formButtonPrimary: "bg-slate-800 hover:bg-slate-900 text-white",
|
||||
formFieldInput: "border border-slate-200 bg-white text-slate-900",
|
||||
footerAction: "text-slate-500",
|
||||
dividerLine: "bg-slate-200",
|
||||
alert: "border border-slate-200 bg-slate-50",
|
||||
otpCodeFieldInput: "border border-slate-200 text-slate-900",
|
||||
formFieldRow: "gap-2",
|
||||
main: "gap-4",
|
||||
},
|
||||
};
|
||||
|
||||
function SignInPage() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<PublicLayout>
|
||||
<Landing />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
<Route>
|
||||
<AppLayout>
|
||||
<div className="flex min-h-[100dvh] items-center justify-center bg-background px-4">
|
||||
<SignIn routing="path" path={`${basePath}/sign-in`} signUpUrl={`${basePath}/sign-up`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignUpPage() {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] items-center justify-center bg-background px-4">
|
||||
<SignUp routing="path" path={`${basePath}/sign-up`} signInUrl={`${basePath}/sign-in`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Keeps the cache fresh when the signed-in user changes.
|
||||
function ClerkQueryClientCacheInvalidator() {
|
||||
const { addListener } = useClerk();
|
||||
const qc = useQueryClient();
|
||||
const prevUserIdRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = addListener(({ user }) => {
|
||||
const userId = user?.id ?? null;
|
||||
if (prevUserIdRef.current !== undefined && prevUserIdRef.current !== userId) {
|
||||
qc.clear();
|
||||
}
|
||||
prevUserIdRef.current = userId;
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [addListener, qc]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ClerkProviderWithRoutes() {
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
return (
|
||||
<ClerkProvider
|
||||
publishableKey={clerkPubKey}
|
||||
proxyUrl={clerkProxyUrl}
|
||||
appearance={clerkAppearance}
|
||||
signInUrl={`${basePath}/sign-in`}
|
||||
signUpUrl={`${basePath}/sign-up`}
|
||||
localization={{
|
||||
signIn: {
|
||||
start: {
|
||||
title: "SkillGuard Administration",
|
||||
subtitle: "Melden Sie sich an, um den Administrationsbereich zu öffnen.",
|
||||
},
|
||||
},
|
||||
signUp: {
|
||||
start: {
|
||||
title: "Konto erstellen",
|
||||
subtitle: "Registrieren Sie sich für den Administrationsbereich.",
|
||||
},
|
||||
},
|
||||
}}
|
||||
routerPush={(to) => setLocation(stripBase(to))}
|
||||
routerReplace={(to) => setLocation(stripBase(to), { replace: true })}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClerkQueryClientCacheInvalidator />
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/pruefen" component={ScanForm} />
|
||||
<Route path="/berichte/:id" component={ScanReport} />
|
||||
<Route path="/vergleich/:id/:otherId" component={ScanCompare} />
|
||||
<Route path="/verlauf" component={ScanHistory} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/impressum" component={Impressum} />
|
||||
<Route path="/haftungsausschluss" component={Haftungsausschluss} />
|
||||
<Route component={NotFound} />
|
||||
{/* Public area */}
|
||||
<Route path="/">
|
||||
<PublicLayout>
|
||||
<Catalog />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
<Route path="/pruefen">
|
||||
<PublicLayout>
|
||||
<ScanForm />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
<Route path="/berichte/:id">
|
||||
<PublicLayout>
|
||||
<ScanReport />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
<Route path="/vergleich/:id/:otherId">
|
||||
<PublicLayout>
|
||||
<ScanCompare />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
<Route path="/impressum">
|
||||
<PublicLayout>
|
||||
<Impressum />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
<Route path="/haftungsausschluss">
|
||||
<PublicLayout>
|
||||
<Haftungsausschluss />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
|
||||
{/* Auth */}
|
||||
<Route path="/sign-in/*?" component={SignInPage} />
|
||||
<Route path="/sign-up/*?" component={SignUpPage} />
|
||||
|
||||
{/* Admin back office */}
|
||||
<Route path="/admin">
|
||||
<RequireAdmin>
|
||||
<AppLayout>
|
||||
<Dashboard />
|
||||
</AppLayout>
|
||||
</RequireAdmin>
|
||||
</Route>
|
||||
<Route path="/admin/verlauf">
|
||||
<RequireAdmin>
|
||||
<AppLayout>
|
||||
<ScanHistory />
|
||||
</AppLayout>
|
||||
</RequireAdmin>
|
||||
</Route>
|
||||
<Route path="/admin/einstellungen">
|
||||
<RequireAdmin>
|
||||
<AppLayout>
|
||||
<Admin />
|
||||
</AppLayout>
|
||||
</RequireAdmin>
|
||||
</Route>
|
||||
|
||||
<Route>
|
||||
<PublicLayout>
|
||||
<NotFound />
|
||||
</PublicLayout>
|
||||
</Route>
|
||||
</Switch>
|
||||
</AppLayout>
|
||||
</Route>
|
||||
</Switch>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
<Router />
|
||||
</WouterRouter>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
<WouterRouter base={basePath}>
|
||||
<ClerkProviderWithRoutes />
|
||||
</WouterRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,61 +1,68 @@
|
|||
import { Link, useLocation } from "wouter";
|
||||
import { Shield, LayoutDashboard, Search, History, Settings } from "lucide-react";
|
||||
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel } from "@/components/ui/sidebar";
|
||||
import { Shield, LayoutDashboard, History, Settings, LogOut, ExternalLink } from "lucide-react";
|
||||
import { useClerk, useUser } from "@clerk/react";
|
||||
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarFooter } from "@/components/ui/sidebar";
|
||||
|
||||
const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
const { signOut } = useClerk();
|
||||
const { user } = useUser();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex min-h-screen w-full bg-background text-foreground">
|
||||
<Sidebar className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
|
||||
<SidebarHeader className="p-4">
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-sidebar-primary" />
|
||||
<span className="font-bold text-lg tracking-tight">SkillGuard</span>
|
||||
</Link>
|
||||
<SidebarHeader className="p-4 flex flex-row items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-sidebar-primary" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-lg tracking-tight leading-none">SkillGuard</span>
|
||||
<span className="text-xs text-sidebar-foreground/50">Administration</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-sidebar-foreground/50">Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupLabel className="text-sidebar-foreground/50">Verwaltung</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location.startsWith("/dashboard")}>
|
||||
<Link href="/dashboard">
|
||||
<SidebarMenuButton asChild isActive={location === "/admin"}>
|
||||
<Link href="/admin">
|
||||
<LayoutDashboard className="w-4 h-4 mr-2" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location === "/pruefen"}>
|
||||
<Link href="/pruefen">
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
<span>Skill Prüfen</span>
|
||||
<SidebarMenuButton asChild isActive={location.startsWith("/admin/verlauf")}>
|
||||
<Link href="/admin/verlauf">
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
<span>Verlauf</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location.startsWith("/verlauf")}>
|
||||
<Link href="/verlauf">
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
<span>Verlauf</span>
|
||||
<SidebarMenuButton asChild isActive={location.startsWith("/admin/einstellungen")}>
|
||||
<Link href="/admin/einstellungen">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
<span>Konfiguration</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup className="mt-auto">
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-sidebar-foreground/50">Öffentlich</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location.startsWith("/admin")}>
|
||||
<Link href="/admin">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
<span>Administration</span>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="/">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
<span>Zum Katalog</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
|
@ -63,6 +70,22 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-4 border-t border-sidebar-border">
|
||||
{user && (
|
||||
<div className="mb-2 px-1 text-xs text-sidebar-foreground/60 truncate">
|
||||
{user.primaryEmailAddress?.emailAddress ?? "Angemeldet"}
|
||||
</div>
|
||||
)}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={() => signOut({ redirectUrl: basePath || "/" })}>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
<span>Abmelden</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<main className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { Link } from "wouter";
|
||||
import { Shield, Search, LayoutDashboard } from "lucide-react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Shield, Search, ShieldCheck, Settings, LayoutDashboard } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Katalog", match: (l: string) => l === "/" },
|
||||
{ href: "/pruefen", label: "Skill prüfen", match: (l: string) => l.startsWith("/pruefen") },
|
||||
];
|
||||
|
||||
export function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col bg-background text-foreground">
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||
<header className="sticky top-0 z-30 border-b border-border bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
|
|
@ -12,28 +20,39 @@ export function PublicLayout({ children }: { children: React.ReactNode }) {
|
|||
<span className="text-lg font-bold tracking-tight">SkillGuard</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/dashboard">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Zum Dashboard</span>
|
||||
<span className="sm:hidden">Dashboard</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/pruefen">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Skill prüfen
|
||||
{NAV.map((item) => (
|
||||
<Button
|
||||
key={item.href}
|
||||
asChild
|
||||
variant={item.match(location) ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
>
|
||||
<Link href={item.href}>
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
<Button asChild variant="outline" size="sm" className="ml-1">
|
||||
<Link href="/admin">
|
||||
<Settings className="mr-1.5 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Administration</span>
|
||||
<span className="sm:hidden">Admin</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
<main className="flex-1">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">{children}</div>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-4 py-6 text-xs text-muted-foreground sm:flex-row sm:px-6">
|
||||
<span>© 2026 avameo GmbH</span>
|
||||
<footer className="border-t border-border bg-background">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 px-4 py-6 text-xs text-muted-foreground sm:flex-row sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<span>© 2026 avameo GmbH</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link href="/impressum" className="transition-colors hover:text-foreground">
|
||||
Impressum
|
||||
|
|
|
|||
74
artifacts/skillguard/src/components/require-admin.tsx
Normal file
74
artifacts/skillguard/src/components/require-admin.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Link } from "wouter";
|
||||
import { useGetMe } from "@workspace/api-client-react";
|
||||
import { Loader2, ShieldX, LogIn } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* Gates admin-only screens. Reads GET /api/me (cookie-based Clerk session) to
|
||||
* decide access: unauthenticated visitors are invited to sign in, signed-in
|
||||
* users who are not on the ADMIN_EMAILS allowlist are refused, and only
|
||||
* allowlisted admins see the wrapped content.
|
||||
*/
|
||||
export function RequireAdmin({ children }: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = useGetMe();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.authenticated) {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<LogIn className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Anmeldung erforderlich</CardTitle>
|
||||
<CardDescription>
|
||||
Der Administrationsbereich ist geschützt. Bitte melden Sie sich an, um fortzufahren.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/sign-in">Anmelden</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/">Zurück zum Katalog</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.isAdmin) {
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<ShieldX className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Kein Zugriff</CardTitle>
|
||||
<CardDescription>
|
||||
Ihr Konto{data.email ? ` (${data.email})` : ""} ist nicht für den Administrationsbereich freigeschaltet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">Zurück zum Katalog</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
@layer theme, base, clerk, components, utilities;
|
||||
@import "tailwindcss";
|
||||
@import "@clerk/themes/shadcn.css";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
|
|
|
|||
170
artifacts/skillguard/src/pages/catalog.tsx
Normal file
170
artifacts/skillguard/src/pages/catalog.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { useListScans } from "@workspace/api-client-react";
|
||||
import type { Scan } from "@workspace/api-client-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { VerdictBadge } from "@/components/ui-helpers";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { Shield, Search, Download, ArrowRight, FileSearch, ShieldCheck } from "lucide-react";
|
||||
|
||||
export default function Catalog() {
|
||||
const { data, isLoading } = useListScans();
|
||||
const [search, setSearch] = useState("");
|
||||
const [verdict, setVerdict] = useState<string>("all");
|
||||
|
||||
const scans = useMemo(() => (data ?? []) as Scan[], [data]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return scans.filter((s) => {
|
||||
if (verdict !== "all" && s.verdict !== verdict) return false;
|
||||
if (q && !s.name.toLowerCase().includes(q) && !(s.description ?? "").toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [scans, search, verdict]);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-primary to-slate-700 px-6 py-12 text-primary-foreground sm:px-12 sm:py-16">
|
||||
<div className="absolute -right-10 -top-10 opacity-10">
|
||||
<Shield className="h-64 w-64" />
|
||||
</div>
|
||||
<div className="relative max-w-2xl space-y-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-medium">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Sicherheits- und Datenschutzprüfung für KI-Skills
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Geprüfte Skills. Transparente Berichte.
|
||||
</h1>
|
||||
<p className="text-base text-primary-foreground/80 sm:text-lg">
|
||||
Durchsuchen Sie den Katalog automatisiert geprüfter Skills, lesen Sie die ausführlichen
|
||||
Sicherheitsberichte oder lassen Sie Ihren eigenen Skill kostenlos analysieren.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
<Button asChild size="lg" variant="secondary" className="gap-2">
|
||||
<Link href="/pruefen">
|
||||
<FileSearch className="h-4 w-4" />
|
||||
Skill prüfen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Skill-Katalog</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scans.length} {scans.length === 1 ? "geprüfter Skill" : "geprüfte Skills"} verfügbar
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Skill suchen …"
|
||||
className="pl-9 sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<Select value={verdict} onValueChange={setVerdict}>
|
||||
<SelectTrigger className="sm:w-44">
|
||||
<SelectValue placeholder="Bewertung" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Bewertungen</SelectItem>
|
||||
<SelectItem value="pass">Unauffällig</SelectItem>
|
||||
<SelectItem value="review">Manuelle Prüfung</SelectItem>
|
||||
<SelectItem value="block">Blockiert</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-card py-16 text-center">
|
||||
<FileSearch className="h-12 w-12 text-muted-foreground opacity-50" />
|
||||
<h3 className="text-lg font-semibold">Keine Skills gefunden</h3>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{scans.length === 0
|
||||
? "Es wurden noch keine Skills geprüft. Prüfen Sie als Erster einen Skill."
|
||||
: "Für die aktuelle Suche bzw. Filter gibt es keine Treffer."}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-2 gap-2">
|
||||
<Link href="/pruefen">
|
||||
<FileSearch className="h-4 w-4" />
|
||||
Skill prüfen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((scan) => (
|
||||
<Card key={scan.id} className="flex flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base leading-snug">
|
||||
<Link href={`/berichte/${scan.id}`} className="hover:underline">
|
||||
{scan.name || `Scan #${scan.id}`}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<VerdictBadge verdict={scan.verdict} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(scan.createdAt)}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<p className="line-clamp-3 flex-1 text-sm text-muted-foreground">
|
||||
{scan.description || "Keine Beschreibung verfügbar."}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={
|
||||
"text-sm font-medium " +
|
||||
(scan.riskScore < 30
|
||||
? "text-emerald-600"
|
||||
: scan.riskScore < 70
|
||||
? "text-amber-600"
|
||||
: "text-rose-600")
|
||||
}
|
||||
>
|
||||
Risiko {scan.riskScore} / 100
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{scan.verdict === "pass" && (
|
||||
<Button asChild size="sm" variant="outline" className="gap-1.5">
|
||||
<a href={`/api/scans/${scan.id}/download`} download>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild size="sm" variant="ghost" className="gap-1">
|
||||
<Link href={`/berichte/${scan.id}`}>
|
||||
Bericht
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useListScans, getListScansQueryKey, useDeleteScan } from "@workspace/api-client-react";
|
||||
import { useListScans, getListScansQueryKey, useDeleteScan, useModerateScan } from "@workspace/api-client-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -11,7 +11,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { VerdictBadge, RelationBadge } from "@/components/ui-helpers";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { Search, Trash2, ArrowRight, X } from "lucide-react";
|
||||
import { Search, Trash2, ArrowRight, X, EyeOff, Eye } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const VERDICT_OPTIONS = [
|
||||
|
|
@ -30,6 +30,7 @@ export default function ScanHistory() {
|
|||
const { data: scans, isLoading } = useListScans();
|
||||
const queryClient = useQueryClient();
|
||||
const deleteScan = useDeleteScan();
|
||||
const moderateScan = useModerateScan();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
@ -70,6 +71,23 @@ export default function ScanHistory() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleToggleHidden = (id: number, hidden: boolean) => {
|
||||
moderateScan.mutate({ id, data: { hidden } }, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: hidden ? "Aus Katalog entfernt" : "Im Katalog sichtbar",
|
||||
description: hidden
|
||||
? "Der Skill wird im öffentlichen Katalog nicht mehr angezeigt."
|
||||
: "Der Skill ist wieder im öffentlichen Katalog sichtbar.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: getListScansQueryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Fehler", description: "Die Sichtbarkeit konnte nicht geändert werden.", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -188,6 +206,11 @@ export default function ScanHistory() {
|
|||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-semibold text-lg">{scan.name || `Scan #${scan.id}`}</span>
|
||||
<VerdictBadge verdict={scan.verdict} />
|
||||
{scan.hidden && (
|
||||
<Badge variant="outline" className="gap-1 border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
<EyeOff className="w-3 h-3" /> Ausgeblendet
|
||||
</Badge>
|
||||
)}
|
||||
{scan.relation && scan.relation !== "new" && <RelationBadge relation={scan.relation} />}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
|
|
@ -225,7 +248,18 @@ export default function ScanHistory() {
|
|||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="border-t sm:border-t-0 sm:border-l p-4 flex items-center justify-center bg-slate-50 dark:bg-slate-900/50">
|
||||
<div className="border-t sm:border-t-0 sm:border-l p-4 flex items-center justify-center gap-1 bg-slate-50 dark:bg-slate-900/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleToggleHidden(scan.id, !scan.hidden)}
|
||||
disabled={moderateScan.isPending}
|
||||
title={scan.hidden ? "Im Katalog anzeigen" : "Aus Katalog ausblenden"}
|
||||
aria-label={scan.hidden ? "Im Katalog anzeigen" : "Aus Katalog ausblenden"}
|
||||
>
|
||||
{scan.hidden ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:text-destructive hover:bg-destructive/10">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
useGetScanLineage,
|
||||
getGetScanLineageQueryKey,
|
||||
useGenerateScanDescription,
|
||||
useGetMe,
|
||||
useModerateScan,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -21,7 +23,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2, Folder, File as FileIcon, Copy, Check, ChevronRight, ChevronDown, EyeOff, Eye, FileArchive } from "lucide-react";
|
||||
import type { ScanDetail } from "@workspace/api-client-react";
|
||||
|
||||
type ScanReportFile = ScanDetail["files"][number];
|
||||
|
|
@ -245,6 +247,26 @@ export default function ScanReport() {
|
|||
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const { data: me } = useGetMe();
|
||||
const isAdmin = me?.isAdmin ?? false;
|
||||
const moderateScan = useModerateScan({
|
||||
mutation: {
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(getGetScanQueryKey(updated.id), (prev: ScanDetail | undefined) =>
|
||||
prev ? { ...prev, hidden: updated.hidden } : prev,
|
||||
);
|
||||
toast({
|
||||
title: updated.hidden ? "Aus Katalog entfernt" : "Im Katalog sichtbar",
|
||||
description: updated.hidden
|
||||
? "Der Skill wird im öffentlichen Katalog nicht mehr angezeigt."
|
||||
: "Der Skill ist wieder im öffentlichen Katalog sichtbar.",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Fehler", description: "Die Sichtbarkeit konnte nicht geändert werden.", variant: "destructive" });
|
||||
},
|
||||
},
|
||||
});
|
||||
const generateDescription = useGenerateScanDescription({
|
||||
mutation: {
|
||||
onSuccess: (updated) => {
|
||||
|
|
@ -351,7 +373,32 @@ export default function ScanReport() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleExportPdf} variant="default" className="gap-2">
|
||||
{data.verdict === "pass" && (!data.hidden || isAdmin) && (
|
||||
<Button asChild variant="default" className="gap-2">
|
||||
<a href={`/api/scans/${data.id}/download`} download>
|
||||
<FileArchive className="w-4 h-4" />
|
||||
Skill herunterladen
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={() => moderateScan.mutate({ id: data.id, data: { hidden: !data.hidden } })}
|
||||
disabled={moderateScan.isPending}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
{moderateScan.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : data.hidden ? (
|
||||
<Eye className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
)}
|
||||
{data.hidden ? "Im Katalog anzeigen" : "Aus Katalog ausblenden"}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleExportPdf} variant="outline" className="gap-2">
|
||||
<FileDown className="w-4 h-4" />
|
||||
Als PDF exportieren
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export default defineConfig({
|
|||
base: basePath,
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
tailwindcss({ optimize: false }),
|
||||
runtimeErrorOverlay(),
|
||||
...(process.env.NODE_ENV !== "production" &&
|
||||
process.env.REPL_ID !== undefined
|
||||
|
|
|
|||
|
|
@ -13,6 +13,21 @@ export interface ApiError {
|
|||
error: string;
|
||||
}
|
||||
|
||||
export interface AuthMe {
|
||||
authenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
/**
|
||||
* The signed-in user's primary email, when available
|
||||
* @nullable
|
||||
*/
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface ScanModerationUpdate {
|
||||
/** Whether to hide the scan from the public catalog */
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export type SkillScanInputSource = typeof SkillScanInputSource[keyof typeof SkillScanInputSource];
|
||||
|
||||
|
||||
|
|
@ -132,6 +147,8 @@ export interface Scan {
|
|||
* @nullable
|
||||
*/
|
||||
comparedScanId: number | null;
|
||||
/** Whether an admin has hidden this scan from the public catalog */
|
||||
hidden: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type {
|
|||
AiProviderInput,
|
||||
AiProviderUpdate,
|
||||
ApiError,
|
||||
AuthMe,
|
||||
DashboardSummary,
|
||||
HealthStatus,
|
||||
Prompt,
|
||||
|
|
@ -38,6 +39,7 @@ import type {
|
|||
ScanComparison,
|
||||
ScanDetail,
|
||||
ScanLineageEntry,
|
||||
ScanModerationUpdate,
|
||||
SkillScanInput
|
||||
} from './api.schemas';
|
||||
|
||||
|
|
@ -131,6 +133,84 @@ export function useHealthCheck<TData = Awaited<ReturnType<typeof healthCheck>>,
|
|||
|
||||
|
||||
|
||||
export const getGetMeUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/me`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the caller is signed in and whether their email is on the admin allowlist. Always 200; never requires auth.
|
||||
* @summary Current authentication and admin status
|
||||
*/
|
||||
export const getMe = async ( options?: RequestInit): Promise<AuthMe> => {
|
||||
|
||||
return customFetch<AuthMe>(getGetMeUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetMeQueryKey = () => {
|
||||
return [
|
||||
`/api/me`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetMeQueryOptions = <TData = Awaited<ReturnType<typeof getMe>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMeQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMe>>> = ({ signal }) => getMe({ signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type GetMeQueryResult = NonNullable<Awaited<ReturnType<typeof getMe>>>
|
||||
export type GetMeQueryError = ErrorType<unknown>
|
||||
|
||||
|
||||
/**
|
||||
* @summary Current authentication and admin status
|
||||
*/
|
||||
|
||||
export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = ErrorType<unknown>>(
|
||||
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMe>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
|
||||
const queryOptions = getGetMeQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetDashboardUrl = () => {
|
||||
|
||||
|
||||
|
|
@ -596,6 +676,79 @@ export function useGetScan<TData = Awaited<ReturnType<typeof getScan>>, TError =
|
|||
|
||||
|
||||
|
||||
export const getModerateScanUrl = (id: number,) => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/scans/${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only. Toggles whether a scan appears in the public catalog.
|
||||
* @summary Moderate a scan (hide or unhide from the public catalog)
|
||||
*/
|
||||
export const moderateScan = async (id: number,
|
||||
scanModerationUpdate: ScanModerationUpdate, options?: RequestInit): Promise<Scan> => {
|
||||
|
||||
return customFetch<Scan>(getModerateScanUrl(id),
|
||||
{
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
scanModerationUpdate,)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getModerateScanMutationOptions = <TError = ErrorType<ApiError>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof moderateScan>>, TError,{id: number;data: BodyType<ScanModerationUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof moderateScan>>, TError,{id: number;data: BodyType<ScanModerationUpdate>}, TContext> => {
|
||||
|
||||
const mutationKey = ['moderateScan'];
|
||||
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 moderateScan>>, {id: number;data: BodyType<ScanModerationUpdate>}> = (props) => {
|
||||
const {id,data} = props ?? {};
|
||||
|
||||
return moderateScan(id,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type ModerateScanMutationResult = NonNullable<Awaited<ReturnType<typeof moderateScan>>>
|
||||
export type ModerateScanMutationBody = BodyType<ScanModerationUpdate>
|
||||
export type ModerateScanMutationError = ErrorType<ApiError>
|
||||
|
||||
/**
|
||||
* @summary Moderate a scan (hide or unhide from the public catalog)
|
||||
*/
|
||||
export const useModerateScan = <TError = ErrorType<ApiError>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof moderateScan>>, TError,{id: number;data: BodyType<ScanModerationUpdate>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof moderateScan>>,
|
||||
TError,
|
||||
{id: number;data: BodyType<ScanModerationUpdate>},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getModerateScanMutationOptions(options));
|
||||
}
|
||||
|
||||
export const getDeleteScanUrl = (id: number,) => {
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ servers:
|
|||
tags:
|
||||
- name: health
|
||||
description: Health operations
|
||||
- name: auth
|
||||
description: Authentication status
|
||||
- name: scans
|
||||
description: Skill scans and audit reports
|
||||
- name: providers
|
||||
|
|
@ -35,6 +37,22 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/HealthStatus"
|
||||
|
||||
/me:
|
||||
get:
|
||||
operationId: getMe
|
||||
tags: [auth]
|
||||
summary: Current authentication and admin status
|
||||
description: >-
|
||||
Returns whether the caller is signed in and whether their email is on
|
||||
the admin allowlist. Always 200; never requires auth.
|
||||
responses:
|
||||
"200":
|
||||
description: Authentication status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthMe"
|
||||
|
||||
/dashboard:
|
||||
get:
|
||||
operationId: getDashboard
|
||||
|
|
@ -181,6 +199,36 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApiError"
|
||||
patch:
|
||||
operationId: moderateScan
|
||||
tags: [scans]
|
||||
summary: Moderate a scan (hide or unhide from the public catalog)
|
||||
description: Admin-only. Toggles whether a scan appears in the public catalog.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ScanModerationUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated scan
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Scan"
|
||||
"404":
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApiError"
|
||||
delete:
|
||||
operationId: deleteScan
|
||||
tags: [scans]
|
||||
|
|
@ -454,6 +502,26 @@ components:
|
|||
error:
|
||||
type: string
|
||||
|
||||
AuthMe:
|
||||
type: object
|
||||
required: [authenticated, isAdmin]
|
||||
properties:
|
||||
authenticated:
|
||||
type: boolean
|
||||
isAdmin:
|
||||
type: boolean
|
||||
email:
|
||||
type: ["string", "null"]
|
||||
description: The signed-in user's primary email, when available
|
||||
|
||||
ScanModerationUpdate:
|
||||
type: object
|
||||
required: [hidden]
|
||||
properties:
|
||||
hidden:
|
||||
type: boolean
|
||||
description: Whether to hide the scan from the public catalog
|
||||
|
||||
SkillScanInput:
|
||||
type: object
|
||||
required: [source, useAi]
|
||||
|
|
@ -493,6 +561,7 @@ components:
|
|||
- relation
|
||||
- similarity
|
||||
- comparedScanId
|
||||
- hidden
|
||||
- createdAt
|
||||
properties:
|
||||
id:
|
||||
|
|
@ -534,6 +603,9 @@ components:
|
|||
comparedScanId:
|
||||
type: ["integer", "null"]
|
||||
description: The scan this one was compared against, if any
|
||||
hidden:
|
||||
type: boolean
|
||||
description: Whether an admin has hidden this scan from the public catalog
|
||||
createdAt:
|
||||
type: string
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,17 @@ export const HealthCheckResponse = zod.object({
|
|||
})
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether the caller is signed in and whether their email is on the admin allowlist. Always 200; never requires auth.
|
||||
* @summary Current authentication and admin status
|
||||
*/
|
||||
export const GetMeResponse = zod.object({
|
||||
"authenticated": zod.boolean(),
|
||||
"isAdmin": zod.boolean(),
|
||||
"email": zod.string().nullish().describe('The signed-in user\'s primary email, when available')
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* Aggregated statistics across all scans.
|
||||
* @summary Dashboard summary
|
||||
|
|
@ -65,6 +76,7 @@ export const GetDashboardResponse = zod.object({
|
|||
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
|
||||
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
|
||||
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
|
||||
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
|
||||
"createdAt": zod.string()
|
||||
})),
|
||||
"topRules": zod.array(zod.object({
|
||||
|
|
@ -104,6 +116,7 @@ export const ListScansResponseItem = zod.object({
|
|||
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
|
||||
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
|
||||
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
|
||||
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
|
||||
"createdAt": zod.string()
|
||||
})
|
||||
export const ListScansResponse = zod.array(ListScansResponseItem)
|
||||
|
|
@ -224,6 +237,7 @@ export const GetScanResponse = zod.object({
|
|||
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
|
||||
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
|
||||
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
|
||||
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
|
||||
"createdAt": zod.string()
|
||||
}).and(zod.object({
|
||||
"files": zod.array(zod.object({
|
||||
|
|
@ -270,6 +284,48 @@ export const GetScanResponse = zod.object({
|
|||
}))
|
||||
|
||||
|
||||
/**
|
||||
* Admin-only. Toggles whether a scan appears in the public catalog.
|
||||
* @summary Moderate a scan (hide or unhide from the public catalog)
|
||||
*/
|
||||
export const ModerateScanParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
})
|
||||
|
||||
export const ModerateScanBody = zod.object({
|
||||
"hidden": zod.boolean().describe('Whether to hide the scan from the public catalog')
|
||||
})
|
||||
|
||||
export const ModerateScanResponse = zod.object({
|
||||
"id": zod.number(),
|
||||
"name": zod.string(),
|
||||
"description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'),
|
||||
"source": zod.enum(['zip', 'file', 'text']),
|
||||
"status": zod.enum(['completed', 'failed']),
|
||||
"verdict": zod.enum(['pass', 'review', 'block']),
|
||||
"riskScore": zod.number(),
|
||||
"fileCount": zod.number(),
|
||||
"aiUsed": zod.boolean(),
|
||||
"aiError": zod.string().nullish(),
|
||||
"findingCounts": zod.object({
|
||||
"critical": zod.number(),
|
||||
"high": zod.number(),
|
||||
"medium": zod.number(),
|
||||
"low": zod.number(),
|
||||
"info": zod.number(),
|
||||
"security": zod.number(),
|
||||
"privacy": zod.number(),
|
||||
"total": zod.number()
|
||||
}),
|
||||
"fingerprint": zod.string().describe('Deterministic hash over all files (path + per-file hash)'),
|
||||
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
|
||||
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
|
||||
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
|
||||
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
|
||||
"createdAt": zod.string()
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @summary Delete a scan report
|
||||
*/
|
||||
|
|
@ -310,6 +366,7 @@ export const GenerateScanDescriptionResponse = zod.object({
|
|||
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
|
||||
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
|
||||
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
|
||||
"hidden": zod.boolean().describe('Whether an admin has hidden this scan from the public catalog'),
|
||||
"createdAt": zod.string()
|
||||
}).and(zod.object({
|
||||
"files": zod.array(zod.object({
|
||||
|
|
|
|||
17
lib/api-zod/src/generated/types/authMe.ts
Normal file
17
lib/api-zod/src/generated/types/authMe.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface AuthMe {
|
||||
authenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
/**
|
||||
* The signed-in user's primary email, when available
|
||||
* @nullable
|
||||
*/
|
||||
email?: string | null;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export * from './aiProviderInputApiType';
|
|||
export * from './aiProviderUpdate';
|
||||
export * from './aiProviderUpdateApiType';
|
||||
export * from './apiError';
|
||||
export * from './authMe';
|
||||
export * from './axisTotals';
|
||||
export * from './comparedScan';
|
||||
export * from './comparedScanVerdict';
|
||||
|
|
@ -58,6 +59,7 @@ export * from './scanFileKind';
|
|||
export * from './scanLineageEntry';
|
||||
export * from './scanLineageEntryRelation';
|
||||
export * from './scanLineageEntryVerdict';
|
||||
export * from './scanModerationUpdate';
|
||||
export * from './scanRelation';
|
||||
export * from './scanSource';
|
||||
export * from './scanStatus';
|
||||
|
|
|
|||
|
|
@ -45,5 +45,7 @@ export interface Scan {
|
|||
* @nullable
|
||||
*/
|
||||
comparedScanId: number | null;
|
||||
/** Whether an admin has hidden this scan from the public catalog */
|
||||
hidden: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
12
lib/api-zod/src/generated/types/scanModerationUpdate.ts
Normal file
12
lib/api-zod/src/generated/types/scanModerationUpdate.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface ScanModerationUpdate {
|
||||
/** Whether to hide the scan from the public catalog */
|
||||
hidden: boolean;
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ export const scansTable = pgTable(
|
|||
relation: text("relation").$type<ScanRelation>(),
|
||||
similarity: integer("similarity"),
|
||||
comparedScanId: integer("compared_scan_id"),
|
||||
hidden: boolean("hidden").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
|
|
|||
212
pnpm-lock.yaml
generated
212
pnpm-lock.yaml
generated
|
|
@ -166,6 +166,12 @@ importers:
|
|||
|
||||
artifacts/api-server:
|
||||
dependencies:
|
||||
'@clerk/express':
|
||||
specifier: ^2.1.23
|
||||
version: 2.1.23(express@5.2.1)
|
||||
'@clerk/shared':
|
||||
specifier: ^4.15.0
|
||||
version: 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@workspace/api-zod':
|
||||
specifier: workspace:*
|
||||
version: link:../../lib/api-zod
|
||||
|
|
@ -184,9 +190,15 @@ importers:
|
|||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
express-rate-limit:
|
||||
specifier: ^8.5.2
|
||||
version: 8.5.2(express@5.2.1)
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
http-proxy-middleware:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
pino:
|
||||
specifier: ^9.14.0
|
||||
version: 9.14.0
|
||||
|
|
@ -406,6 +418,13 @@ importers:
|
|||
version: 3.25.76
|
||||
|
||||
artifacts/skillguard:
|
||||
dependencies:
|
||||
'@clerk/react':
|
||||
specifier: ^6.7.3
|
||||
version: 6.7.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@clerk/themes':
|
||||
specifier: ^2.4.57
|
||||
version: 2.4.57(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
devDependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.10.0
|
||||
|
|
@ -738,6 +757,51 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@clerk/backend@3.5.0':
|
||||
resolution: {integrity: sha512-g1bjzqj7/mb6rnrQ5zr+ybjpilTIWADaCoHE1HnEAjQfbcdfn3gb9PPX0wJ1KWw8OOQEQYSjzXPzvofEB1j+ww==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@clerk/express@2.1.23':
|
||||
resolution: {integrity: sha512-Z+KK5im5yf33nWpEbzEHbWJDIROqTKl/ZgbTPl+zDmD+WZ0kWH+nqXTtq6wb6pN+k1cu4RRoRJLc6aZ3AYQuIA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
express: ^4.17.0 || ^5.0.0
|
||||
|
||||
'@clerk/react@6.7.3':
|
||||
resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
|
||||
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
|
||||
|
||||
'@clerk/shared@3.47.7':
|
||||
resolution: {integrity: sha512-9Yv4MJFEaC7BzV0whxa4txQ4SoMu/3j1LBnI85EBykb5CcfXxIKvNX/9sjMUUySHlTOjsj7XZa5i3W5Dx02K/Q==}
|
||||
engines: {node: '>=18.17.0'}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
|
||||
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
'@clerk/shared@4.15.0':
|
||||
resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
|
||||
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
'@clerk/themes@2.4.57':
|
||||
resolution: {integrity: sha512-Nb3bO79rMTU/MPVTC/dde6LG27/IgOMKIYi5KSvAmO4ZUHlj0OWufu6CMvz5OYVZ0YdyMnTBU2aPGRUiRzO+2w==}
|
||||
engines: {node: '>=18.17.0'}
|
||||
|
||||
'@commander-js/extra-typings@14.0.0':
|
||||
resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==}
|
||||
peerDependencies:
|
||||
|
|
@ -1571,6 +1635,9 @@ packages:
|
|||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
|
|
@ -1921,6 +1988,9 @@ packages:
|
|||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
|
|
@ -1996,6 +2066,10 @@ packages:
|
|||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -2212,6 +2286,12 @@ packages:
|
|||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express-rate-limit@8.5.2:
|
||||
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
|
@ -2233,6 +2313,9 @@ packages:
|
|||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
|
|
@ -2332,6 +2415,9 @@ packages:
|
|||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
glob-to-regexp@0.4.1:
|
||||
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
||||
|
||||
globby@16.1.0:
|
||||
resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -2358,6 +2444,13 @@ packages:
|
|||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-proxy-middleware@4.1.0:
|
||||
resolution: {integrity: sha512-XUOAjKncPZjsrgDTTem+CUED6A+piUAKTOwBM8g1gAublBX/GdxTHP8mO+og1EW1evYP8wd0G2c111tExwo/jw==}
|
||||
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
|
||||
|
||||
httpxy@0.5.3:
|
||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||
|
||||
human-signals@8.0.1:
|
||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
|
@ -2383,6 +2476,10 @@ packages:
|
|||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
|
@ -2429,6 +2526,10 @@ packages:
|
|||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-cookie@3.0.7:
|
||||
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
|
@ -2994,10 +3095,16 @@ packages:
|
|||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
|
|
@ -3017,6 +3124,11 @@ packages:
|
|||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
swr@2.3.4:
|
||||
resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==}
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
|
|
@ -3416,6 +3528,62 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@clerk/backend@3.5.0':
|
||||
dependencies:
|
||||
'@clerk/shared': 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
standardwebhooks: 1.0.0
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
'@clerk/express@2.1.23(express@5.2.1)':
|
||||
dependencies:
|
||||
'@clerk/backend': 3.5.0
|
||||
'@clerk/shared': 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
express: 5.2.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
'@clerk/react@6.7.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@clerk/shared': 4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@clerk/shared@3.47.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
dequal: 2.0.3
|
||||
glob-to-regexp: 0.4.1
|
||||
js-cookie: 3.0.7
|
||||
std-env: 3.10.0
|
||||
swr: 2.3.4(react@19.1.0)
|
||||
optionalDependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
'@clerk/shared@4.15.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.100.9
|
||||
dequal: 2.0.3
|
||||
glob-to-regexp: 0.4.1
|
||||
js-cookie: 3.0.7
|
||||
optionalDependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
'@clerk/themes@2.4.57(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@clerk/shared': 3.47.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
'@commander-js/extra-typings@14.0.0(commander@14.0.3)':
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
|
|
@ -4373,6 +4541,8 @@ snapshots:
|
|||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tabby_ai/hijri-converter@1.0.5': {}
|
||||
|
|
@ -4741,6 +4911,8 @@ snapshots:
|
|||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
|
|
@ -4797,6 +4969,8 @@ snapshots:
|
|||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
|
@ -4916,6 +5090,11 @@ snapshots:
|
|||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
express-rate-limit@8.5.2(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.2.0
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
|
|
@ -4965,6 +5144,8 @@ snapshots:
|
|||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fastq@1.20.1:
|
||||
|
|
@ -5062,6 +5243,8 @@ snapshots:
|
|||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob-to-regexp@0.4.1: {}
|
||||
|
||||
globby@16.1.0:
|
||||
dependencies:
|
||||
'@sindresorhus/merge-streams': 4.0.0
|
||||
|
|
@ -5091,6 +5274,18 @@ snapshots:
|
|||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-middleware@4.1.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
httpxy: 0.5.3
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 4.1.0
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpxy@0.5.3: {}
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
|
|
@ -5108,6 +5303,8 @@ snapshots:
|
|||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
|
@ -5134,6 +5331,8 @@ snapshots:
|
|||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-cookie@3.0.7: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
|
|
@ -5707,8 +5906,15 @@ snapshots:
|
|||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
|
@ -5721,6 +5927,12 @@ snapshots:
|
|||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
swr@2.3.4(react@19.1.0):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
react: 19.1.0
|
||||
use-sync-external-store: 1.6.0(react@19.1.0)
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.3.0):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue