diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index a076e0d..7878b28 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -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:/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. diff --git a/.agents/memory/clerk-shadcn-theme-tailwind.md b/.agents/memory/clerk-shadcn-theme-tailwind.md new file mode 100644 index 0000000..a6e250b --- /dev/null +++ b/.agents/memory/clerk-shadcn-theme-tailwind.md @@ -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 ``/`` appear unstyled or mismatched despite the import being present. diff --git a/.replit b/.replit index cf5c912..8d0abcc 100644 --- a/.replit +++ b/.replit @@ -47,6 +47,10 @@ externalPort = 80 localPort = 8081 externalPort = 8081 +[[ports]] +localPort = 8082 +externalPort = 3001 + [[ports]] localPort = 20892 externalPort = 3000 diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 59b62df..ef084a9 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -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" }, diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index 41eae81..ffade8a 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -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; diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts new file mode 100644 index 0000000..349f86b --- /dev/null +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -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 { + 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 { + 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(); +} diff --git a/artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts b/artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts new file mode 100644 index 0000000..11834b8 --- /dev/null +++ b/artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts @@ -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; +} diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts new file mode 100644 index 0000000..8934d28 --- /dev/null +++ b/artifacts/api-server/src/routes/auth.ts @@ -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; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 580e827..759f5ac 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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; diff --git a/artifacts/api-server/src/routes/providers.listModels.test.ts b/artifacts/api-server/src/routes/providers.listModels.test.ts index 0a503a5..629fd4c 100644 --- a/artifacts/api-server/src/routes/providers.listModels.test.ts +++ b/artifacts/api-server/src/routes/providers.listModels.test.ts @@ -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"; diff --git a/artifacts/api-server/src/routes/rules.ts b/artifacts/api-server/src/routes/rules.ts index d330254..8018214 100644 --- a/artifacts/api-server/src/routes/rules.ts +++ b/artifacts/api-server/src/routes/rules.ts @@ -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); diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts index 8279d4e..b61f4b7 100644 --- a/artifacts/api-server/src/routes/scans.ts +++ b/artifacts/api-server/src/routes/scans.ts @@ -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((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 = {}; + 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" }); diff --git a/artifacts/skillguard/package.json b/artifacts/skillguard/package.json index 182f223..82a629e 100644 --- a/artifacts/skillguard/package.json +++ b/artifacts/skillguard/package.json @@ -73,5 +73,9 @@ "vite": "catalog:", "wouter": "^3.3.5", "zod": "catalog:" + }, + "dependencies": { + "@clerk/react": "^6.7.3", + "@clerk/themes": "^2.4.57" } } diff --git a/artifacts/skillguard/public/logo.svg b/artifacts/skillguard/public/logo.svg new file mode 100644 index 0000000..f0e19e3 --- /dev/null +++ b/artifacts/skillguard/public/logo.svg @@ -0,0 +1,5 @@ + + + + SkillGuard + diff --git a/artifacts/skillguard/public/opengraph.jpg b/artifacts/skillguard/public/opengraph.jpg index 1715a0b..48325ff 100644 Binary files a/artifacts/skillguard/public/opengraph.jpg and b/artifacts/skillguard/public/opengraph.jpg differ diff --git a/artifacts/skillguard/src/App.tsx b/artifacts/skillguard/src/App.tsx index c486795..fe18913 100644 --- a/artifacts/skillguard/src/App.tsx +++ b/artifacts/skillguard/src/App.tsx @@ -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 ( - - - - - - - - +
+ +
+ ); +} + +function SignUpPage() { + return ( +
+ +
+ ); +} + +// Keeps the cache fresh when the signed-in user changes. +function ClerkQueryClientCacheInvalidator() { + const { addListener } = useClerk(); + const qc = useQueryClient(); + const prevUserIdRef = useRef(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 ( + setLocation(stripBase(to))} + routerReplace={(to) => setLocation(stripBase(to), { replace: true })} + > + + + - - - - - - - - - + {/* Public area */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Auth */} + + + + {/* Admin back office */} + + + + + + + + + + + + + + + + + + + + + + + + + + + -
-
-
+ + + + ); } function App() { return ( - - - - - - - - + + + ); } diff --git a/artifacts/skillguard/src/components/layout.tsx b/artifacts/skillguard/src/components/layout.tsx index e03b39e..300503c 100644 --- a/artifacts/skillguard/src/components/layout.tsx +++ b/artifacts/skillguard/src/components/layout.tsx @@ -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 (
- - - - SkillGuard - + + +
+ SkillGuard + Administration +
- Navigation + Verwaltung - - + + Dashboard - - - - Skill Prüfen + + + + Verlauf - - - - Verlauf + + + + Konfiguration - - + + + Öffentlich - - - - Administration + + + + Zum Katalog @@ -63,6 +70,22 @@ export function AppLayout({ children }: { children: React.ReactNode }) { + + + {user && ( +
+ {user.primaryEmailAddress?.emailAddress ?? "Angemeldet"} +
+ )} + + + signOut({ redirectUrl: basePath || "/" })}> + + Abmelden + + + +
diff --git a/artifacts/skillguard/src/components/public-layout.tsx b/artifacts/skillguard/src/components/public-layout.tsx index bdb620c..cf2e14b 100644 --- a/artifacts/skillguard/src/components/public-layout.tsx +++ b/artifacts/skillguard/src/components/public-layout.tsx @@ -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 ( -
+
@@ -12,28 +20,39 @@ export function PublicLayout({ children }: { children: React.ReactNode }) { SkillGuard
-
{children}
+
+
{children}
+
-