Replace Clerk with custom email+password authentication

Task: Replace Clerk (Replit-managed) with a standalone JWT/cookie-based auth system.

## What changed

### Backend (api-server)
- Added `admin_users` table (lib/db/src/schema/adminUsers.ts) with id, email (unique), password_hash, created_at; pushed to DB with drizzle-kit push
- Replaced `resolveAuth`/`requireAdmin` in auth.ts middleware: now reads a signed HS256 JWT from the `session` httpOnly cookie (via `jose`) instead of Clerk tokens
- Added `POST /api/auth/login` (bcrypt password check → sets httpOnly cookie), `POST /api/auth/logout` (clears cookie), `GET /api/me` (unchanged contract)
- Added `seedAdminUser()` in lib/seedAdmin.ts: on startup, if no admin exists, creates one from ADMIN_EMAIL + ADMIN_PASSWORD env vars (bcrypt-hashed)
- Removed all Clerk imports from app.ts: clerkMiddleware, publishableKeyFromHost, clerkProxyMiddleware deleted
- Deleted clerkProxyMiddleware.ts entirely
- Added cookie-parser middleware to app.ts
- Removed @clerk/express, @clerk/shared from package.json; added jose, bcryptjs, @types/bcryptjs

### Frontend (skillguard)
- Removed ClerkProvider, SignIn, SignUp, ClerkQueryClientCacheInvalidator from App.tsx; replaced with plain wouter routes
- Replaced /sign-in and /sign-up routes with a single /sign-in route pointing to new LoginPage
- New LoginPage (src/pages/login.tsx): email+password form using shadcn Input/Button/Card, calls POST /api/auth/login, redirects to /admin on success
- layout.tsx: replaced useClerk/useUser with useGetMe() + fetch POST /api/auth/logout
- require-admin.tsx: unchanged logic (already used useGetMe()), updated comment
- Removed @clerk/react, @clerk/localizations, @clerk/themes from package.json
- Added signInButton + loginError i18n keys to all 3 locales (de/en/es)

## New secrets required
- SESSION_SECRET (already existed)
- ADMIN_EMAIL (new — first admin email)
- ADMIN_PASSWORD (new — first admin password, stored as bcrypt hash)

## Removed env vars
- CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, VITE_CLERK_PUBLISHABLE_KEY, VITE_CLERK_PROXY_URL (can be deleted from secrets)

## Test results
All 79 tests pass.

Replit-Task-Id: 41d32d48-8f20-44bc-b665-a2becb83e503
This commit is contained in:
amertensreplit 2026-06-16 21:22:55 +00:00
parent 9648b8553c
commit 441c828a17
20 changed files with 351 additions and 629 deletions

View file

@ -5,5 +5,5 @@
- [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). - [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. - [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. - [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. - [Custom JWT cookie auth](custom-jwt-auth.md) — auth uses jose HS256 JWT in httpOnly `session` cookie; SESSION_SECRET required; admin seeded once from ADMIN_EMAIL+ADMIN_PASSWORD env vars.
- [/api/rules localization](rules-endpoint-localization.md) — list-rules endpoint must localize by `lang` query (not just scan findings) or German leaks into EN/ES catalog/admin. - [/api/rules localization](rules-endpoint-localization.md) — list-rules endpoint must localize by `lang` query (not just scan findings) or German leaks into EN/ES catalog/admin.

View file

@ -0,0 +1,20 @@
---
name: Custom JWT cookie auth
description: How SkillGuard's custom email+password authentication works after replacing Clerk.
---
# Custom JWT Cookie Auth
Clerk was replaced with a standalone auth system.
**Rule:** Use `jose` (HS256) for JWT signing/verification. The token lives in an httpOnly `session` cookie. `SESSION_SECRET` env var must be set.
**Why:** Removed Clerk dependency for self-contained auth with no external service or Replit binding.
**How to apply:**
- `artifacts/api-server/src/middlewares/auth.ts``resolveAuth()` reads the cookie; `signToken()` creates JWTs
- `artifacts/api-server/src/routes/auth.ts``POST /api/auth/login` (bcrypt check → set cookie), `POST /api/auth/logout` (clear cookie), `GET /api/me`
- `lib/db/src/schema/adminUsers.ts``admin_users` table with email (unique) + password_hash
- `artifacts/api-server/src/lib/seedAdmin.ts` — seeds one admin from `ADMIN_EMAIL`+`ADMIN_PASSWORD` on startup if table is empty
- Cookie is `sameSite: lax`, `secure: true` in production, `httpOnly: true`, 30-day expiry
- All authenticated users in the cookie are considered admins (no separate role check needed)

View file

@ -11,21 +11,21 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@clerk/express": "^2.1.23",
"@clerk/shared": "^4.15.0",
"@workspace/api-zod": "workspace:*", "@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*", "@workspace/db": "workspace:*",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"http-proxy-middleware": "^4.1.0", "jose": "^5.10.0",
"pino": "^9.14.0", "pino": "^9.14.0",
"pino-http": "^10.5.0" "pino-http": "^10.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",

View file

@ -1,13 +1,7 @@
import express, { type Express } from "express"; import express, { type Express } from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import pinoHttp from "pino-http"; 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 router from "./routes";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
@ -36,25 +30,10 @@ app.use(
}), }),
); );
// 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(cors({ credentials: true, origin: true }));
app.use(express.json({ limit: "25mb" })); app.use(express.json({ limit: "25mb" }));
app.use(express.urlencoded({ extended: true, limit: "25mb" })); app.use(express.urlencoded({ extended: true, limit: "25mb" }));
app.use(cookieParser());
// 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); app.use("/api", router);

View file

@ -1,6 +1,7 @@
import app from "./app"; import app from "./app";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
import { seedDefaults } from "./lib/seed"; import { seedDefaults } from "./lib/seed";
import { seedAdminUser } from "./lib/seedAdmin";
const rawPort = process.env["PORT"]; const rawPort = process.env["PORT"];
@ -24,4 +25,5 @@ app.listen(port, (err) => {
logger.info({ port }, "Server listening"); logger.info({ port }, "Server listening");
void seedDefaults(); void seedDefaults();
void seedAdminUser();
}); });

View file

@ -0,0 +1,27 @@
import bcrypt from "bcryptjs";
import { db, adminUsersTable } from "@workspace/db";
import { count } from "drizzle-orm";
import { logger } from "./logger";
export async function seedAdminUser(): Promise<void> {
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) {
logger.warn("ADMIN_EMAIL or ADMIN_PASSWORD not set — skipping admin seed.");
return;
}
try {
const [row] = await db.select({ c: count() }).from(adminUsersTable);
if (Number(row?.c ?? 0) > 0) {
return;
}
const passwordHash = await bcrypt.hash(password, 12);
await db.insert(adminUsersTable).values({ email, passwordHash });
logger.info({ email }, "Admin-Benutzer angelegt.");
} catch (err) {
logger.error({ err }, "Admin-Seed fehlgeschlagen.");
}
}

View file

@ -1,50 +1,44 @@
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { getAuth, clerkClient } from "@clerk/express"; import * as jose from "jose";
import { logger } from "../lib/logger"; 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 = { export type AuthInfo = {
userId: string | null; userId: string | null;
email: string | null; email: string | null;
isAdmin: boolean; isAdmin: boolean;
}; };
/** function getSecret(): Uint8Array {
* Resolve the caller's authentication state without throwing. Anonymous const secret = process.env.SESSION_SECRET;
* visitors short-circuit before any Clerk API call so public endpoints stay if (!secret) throw new Error("SESSION_SECRET is not set");
* fast; only signed-in users incur a user lookup to read their email. return new TextEncoder().encode(secret);
*/ }
export async function resolveAuth(req: Request): Promise<AuthInfo> { export async function resolveAuth(req: Request): Promise<AuthInfo> {
const auth = getAuth(req); const token = req.cookies?.session;
const userId = auth?.userId ?? null; if (!token) return { userId: null, email: null, isAdmin: false };
if (!userId) return { userId: null, email: null, isAdmin: false };
let email: string | null = null;
try { try {
const user = await clerkClient.users.getUser(userId); const { payload } = await jose.jwtVerify(token, getSecret(), {
email = algorithms: ["HS256"],
user.primaryEmailAddress?.emailAddress ?? });
user.emailAddresses[0]?.emailAddress ?? const email = typeof payload.email === "string" ? payload.email : null;
null; const userId = typeof payload.sub === "string" ? payload.sub : null;
if (!email || !userId) return { userId: null, email: null, isAdmin: false };
return { userId, email, isAdmin: true };
} catch (err) { } catch (err) {
logger.error({ err }, "Clerk-Benutzer konnte nicht geladen werden"); logger.debug({ err }, "JWT verification failed");
email = null; return { userId: null, email: null, isAdmin: false };
} }
}
const allowlist = getAdminAllowlist(); export async function signToken(userId: number, email: string): Promise<string> {
const isAdmin = !!email && allowlist.includes(email.toLowerCase()); return new jose.SignJWT({ email })
return { userId, email, isAdmin }; .setProtectedHeader({ alg: "HS256" })
.setSubject(String(userId))
.setIssuedAt()
.setExpirationTime("30d")
.sign(getSecret());
} }
declare global { declare global {

View file

@ -1,91 +0,0 @@
/**
* 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;
}

View file

@ -1,9 +1,15 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import bcrypt from "bcryptjs";
import { db, adminUsersTable } from "@workspace/db";
import { eq } from "drizzle-orm";
import { GetMeResponse } from "@workspace/api-zod"; import { GetMeResponse } from "@workspace/api-zod";
import { resolveAuth } from "../middlewares/auth"; import { resolveAuth, signToken } from "../middlewares/auth";
const router: IRouter = Router(); const router: IRouter = Router();
const COOKIE_NAME = "session";
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
router.get("/me", async (req, res) => { router.get("/me", async (req, res) => {
const info = await resolveAuth(req); const info = await resolveAuth(req);
res.json( res.json(
@ -15,4 +21,39 @@ router.get("/me", async (req, res) => {
); );
}); });
router.post("/auth/login", async (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== "string" || typeof password !== "string") {
res.status(400).json({ error: "E-Mail und Passwort erforderlich." });
return;
}
const [user] = await db
.select()
.from(adminUsersTable)
.where(eq(adminUsersTable.email, email.trim().toLowerCase()));
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
res.status(401).json({ error: "E-Mail oder Passwort falsch." });
return;
}
const token = await signToken(user.id, user.email);
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: COOKIE_MAX_AGE,
path: "/",
});
res.json({ ok: true, email: user.email });
});
router.post("/auth/logout", (_req, res) => {
res.clearCookie(COOKIE_NAME, { path: "/" });
res.json({ ok: true });
});
export default router; export default router;

View file

@ -75,9 +75,6 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"dependencies": { "dependencies": {
"@clerk/localizations": "^4.8.1",
"@clerk/react": "^6.7.3",
"@clerk/themes": "^2.4.57",
"i18next": "^26.3.1", "i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"react-i18next": "^17.0.8" "react-i18next": "^17.0.8"

View file

@ -1,18 +1,12 @@
import { useEffect, useRef } from "react"; import { Switch, Route, Router as WouterRouter } from "wouter";
import { useTranslation } from "react-i18next"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ClerkProvider, SignIn, SignUp, useClerk } from "@clerk/react";
import { publishableKeyFromHost } from "@clerk/react/internal";
import { shadcn } from "@clerk/themes";
import { deDE, enUS, esES } from "@clerk/localizations";
import { type AppLanguage } from "@/i18n";
import { Switch, Route, useLocation, Router as WouterRouter } from "wouter";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { PublicLayout } from "@/components/public-layout"; import { PublicLayout } from "@/components/public-layout";
import { AppLayout } from "@/components/layout"; import { AppLayout } from "@/components/layout";
import { RequireAdmin } from "@/components/require-admin"; import { RequireAdmin } from "@/components/require-admin";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
import LoginPage from "@/pages/login";
import Catalog from "@/pages/catalog"; import Catalog from "@/pages/catalog";
import Dashboard from "@/pages/dashboard"; import Dashboard from "@/pages/dashboard";
@ -24,248 +18,89 @@ import Admin from "@/pages/admin";
import Impressum from "@/pages/impressum"; import Impressum from "@/pages/impressum";
import Haftungsausschluss from "@/pages/haftungsausschluss"; import Haftungsausschluss from "@/pages/haftungsausschluss";
type ClerkLocalization = typeof deDE;
// 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(/\/$/, ""); 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(); const queryClient = new QueryClient();
const clerkAppearance = { function AppRoutes() {
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 ( return (
<div className="flex min-h-[100dvh] items-center justify-center bg-background px-4"> <QueryClientProvider client={queryClient}>
<SignIn routing="path" path={`${basePath}/sign-in`} signUpUrl={`${basePath}/sign-up`} /> <TooltipProvider>
</div> <Switch>
); {/* 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>
function SignUpPage() { {/* Auth */}
return ( <Route path="/sign-in" component={LoginPage} />
<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. {/* Admin back office */}
function ClerkQueryClientCacheInvalidator() { <Route path="/admin">
const { addListener } = useClerk(); <RequireAdmin>
const qc = useQueryClient(); <AppLayout>
const prevUserIdRef = useRef<string | null | undefined>(undefined); <Dashboard />
</AppLayout>
</RequireAdmin>
</Route>
<Route path="/admin/verlauf">
<RequireAdmin>
<AppLayout>
<ScanHistory />
</AppLayout>
</RequireAdmin>
</Route>
<Route path="/admin/einstellungen">
<RequireAdmin>
<AppLayout>
<Admin />
</AppLayout>
</RequireAdmin>
</Route>
useEffect(() => { <Route>
const unsubscribe = addListener(({ user }) => { <PublicLayout>
const userId = user?.id ?? null; <NotFound />
if (prevUserIdRef.current !== undefined && prevUserIdRef.current !== userId) { </PublicLayout>
qc.clear(); </Route>
} </Switch>
prevUserIdRef.current = userId; <Toaster />
}); </TooltipProvider>
return unsubscribe; </QueryClientProvider>
}, [addListener, qc]);
return null;
}
const CLERK_BASE_LOCALIZATIONS: Record<AppLanguage, ClerkLocalization> = {
de: deDE,
en: enUS,
es: esES,
};
function buildClerkLocalization(
lang: AppLanguage,
t: (key: string) => string,
): ClerkLocalization {
const base = CLERK_BASE_LOCALIZATIONS[lang];
return {
...base,
signIn: {
...base.signIn,
start: {
...base.signIn?.start,
title: t("common.auth.signInTitle"),
subtitle: t("common.auth.signInSubtitle"),
},
},
signUp: {
...base.signUp,
start: {
...base.signUp?.start,
title: t("common.auth.signUpTitle"),
subtitle: t("common.auth.signUpSubtitle"),
},
},
};
}
function ClerkProviderWithRoutes() {
const [, setLocation] = useLocation();
const { t, i18n } = useTranslation();
const lang = (i18n.resolvedLanguage ?? i18n.language ?? "de").slice(
0,
2,
) as AppLanguage;
return (
<ClerkProvider
publishableKey={clerkPubKey}
proxyUrl={clerkProxyUrl}
appearance={clerkAppearance}
signInUrl={`${basePath}/sign-in`}
signUpUrl={`${basePath}/sign-up`}
localization={buildClerkLocalization(lang, t)}
routerPush={(to) => setLocation(stripBase(to))}
routerReplace={(to) => setLocation(stripBase(to), { replace: true })}
>
<QueryClientProvider client={queryClient}>
<ClerkQueryClientCacheInvalidator />
<TooltipProvider>
<Switch>
{/* 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>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
</ClerkProvider>
); );
} }
function App() { function App() {
return ( return (
<WouterRouter base={basePath}> <WouterRouter base={basePath}>
<ClerkProviderWithRoutes /> <AppRoutes />
</WouterRouter> </WouterRouter>
); );
} }

View file

@ -1,17 +1,27 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Shield, LayoutDashboard, History, Settings, LogOut, ExternalLink } from "lucide-react"; import { Shield, LayoutDashboard, History, Settings, LogOut, ExternalLink } from "lucide-react";
import { useClerk, useUser } from "@clerk/react"; import { useGetMe } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarFooter } from "@/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarFooter } from "@/components/ui/sidebar";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
export function AppLayout({ children }: { children: React.ReactNode }) { export function AppLayout({ children }: { children: React.ReactNode }) {
const [location] = useLocation(); const [location, setLocation] = useLocation();
const { signOut } = useClerk();
const { user } = useUser();
const { t } = useTranslation(); const { t } = useTranslation();
const { data: me } = useGetMe();
const qc = useQueryClient();
async function handleSignOut() {
await fetch(`${basePath}/api/auth/logout`, {
method: "POST",
credentials: "include",
});
await qc.invalidateQueries();
setLocation("/");
}
return ( return (
<SidebarProvider> <SidebarProvider>
@ -75,14 +85,14 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-4 border-t border-sidebar-border"> <SidebarFooter className="p-4 border-t border-sidebar-border">
{user && ( {me?.email && (
<div className="mb-2 px-1 text-xs text-sidebar-foreground/60 truncate"> <div className="mb-2 px-1 text-xs text-sidebar-foreground/60 truncate">
{user.primaryEmailAddress?.emailAddress ?? t("common.adminLayout.signedIn")} {me.email}
</div> </div>
)} )}
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton onClick={() => signOut({ redirectUrl: basePath || "/" })}> <SidebarMenuButton onClick={handleSignOut}>
<LogOut className="w-4 h-4 mr-2" /> <LogOut className="w-4 h-4 mr-2" />
<span>{t("common.adminLayout.signOut")}</span> <span>{t("common.adminLayout.signOut")}</span>
</SidebarMenuButton> </SidebarMenuButton>

View file

@ -5,10 +5,9 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
/** /**
* Gates admin-only screens. Reads GET /api/me (cookie-based Clerk session) to * Gates admin-only screens. Reads GET /api/me (httpOnly cookie session) to
* decide access: unauthenticated visitors are invited to sign in, signed-in * decide access: unauthenticated visitors are invited to sign in, and only
* users who are not on the ADMIN_EMAILS allowlist are refused, and only * authenticated admins see the wrapped content.
* allowlisted admins see the wrapped content.
*/ */
export function RequireAdmin({ children }: { children: React.ReactNode }) { export function RequireAdmin({ children }: { children: React.ReactNode }) {
const { data, isLoading } = useGetMe(); const { data, isLoading } = useGetMe();

View file

@ -50,6 +50,8 @@ export default {
signInSubtitle: "Melden Sie sich an, um den Administrationsbereich zu öffnen.", signInSubtitle: "Melden Sie sich an, um den Administrationsbereich zu öffnen.",
signUpTitle: "Konto erstellen", signUpTitle: "Konto erstellen",
signUpSubtitle: "Registrieren Sie sich für den Administrationsbereich.", signUpSubtitle: "Registrieren Sie sich für den Administrationsbereich.",
signInButton: "Anmelden",
loginError: "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.",
}, },
adminLayout: { adminLayout: {
subtitle: "Administration", subtitle: "Administration",

View file

@ -50,6 +50,8 @@ export default {
signInSubtitle: "Sign in to open the administration area.", signInSubtitle: "Sign in to open the administration area.",
signUpTitle: "Create account", signUpTitle: "Create account",
signUpSubtitle: "Register for the administration area.", signUpSubtitle: "Register for the administration area.",
signInButton: "Sign in",
loginError: "Login failed. Please check your credentials.",
}, },
adminLayout: { adminLayout: {
subtitle: "Administration", subtitle: "Administration",

View file

@ -50,6 +50,8 @@ export default {
signInSubtitle: "Inicie sesión para abrir el área de administración.", signInSubtitle: "Inicie sesión para abrir el área de administración.",
signUpTitle: "Crear cuenta", signUpTitle: "Crear cuenta",
signUpSubtitle: "Regístrese para el área de administración.", signUpSubtitle: "Regístrese para el área de administración.",
signInButton: "Iniciar sesión",
loginError: "Error al iniciar sesión. Por favor, verifique sus credenciales.",
}, },
adminLayout: { adminLayout: {
subtitle: "Administración", subtitle: "Administración",

View file

@ -0,0 +1,94 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { useTranslation } from "react-i18next";
import { Shield, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { useQueryClient } from "@tanstack/react-query";
const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
export default function LoginPage() {
const [, setLocation] = useLocation();
const { t } = useTranslation();
const qc = useQueryClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`${basePath}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error ?? t("common.auth.loginError"));
} else {
await qc.invalidateQueries();
setLocation("/admin");
}
} catch {
setError(t("common.auth.loginError"));
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-[100dvh] items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Shield className="h-6 w-6 text-primary" />
</div>
<CardTitle>{t("common.auth.signInTitle")}</CardTitle>
<CardDescription>{t("common.auth.signInSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" disabled={loading} className="w-full">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("common.auth.signInButton")}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const adminUsersTable = pgTable("admin_users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export type AdminUser = typeof adminUsersTable.$inferSelect;
export type InsertAdminUser = typeof adminUsersTable.$inferInsert;

View file

@ -4,3 +4,4 @@ export * from "./findings";
export * from "./aiProviders"; export * from "./aiProviders";
export * from "./prompts"; export * from "./prompts";
export * from "./rules"; export * from "./rules";
export * from "./adminUsers";

251
pnpm-lock.yaml generated
View file

@ -166,18 +166,15 @@ importers:
artifacts/api-server: artifacts/api-server:
dependencies: 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': '@workspace/api-zod':
specifier: workspace:* specifier: workspace:*
version: link:../../lib/api-zod version: link:../../lib/api-zod
'@workspace/db': '@workspace/db':
specifier: workspace:* specifier: workspace:*
version: link:../../lib/db version: link:../../lib/db
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
cookie-parser: cookie-parser:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7
@ -196,9 +193,9 @@ importers:
fflate: fflate:
specifier: ^0.8.3 specifier: ^0.8.3
version: 0.8.3 version: 0.8.3
http-proxy-middleware: jose:
specifier: ^4.1.0 specifier: ^5.10.0
version: 4.1.0 version: 5.10.0
pino: pino:
specifier: ^9.14.0 specifier: ^9.14.0
version: 9.14.0 version: 9.14.0
@ -206,6 +203,9 @@ importers:
specifier: ^10.5.0 specifier: ^10.5.0
version: 10.5.0 version: 10.5.0
devDependencies: devDependencies:
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
'@types/cookie-parser': '@types/cookie-parser':
specifier: ^1.4.10 specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.6) version: 1.4.10(@types/express@5.0.6)
@ -419,15 +419,6 @@ importers:
artifacts/skillguard: artifacts/skillguard:
dependencies: dependencies:
'@clerk/localizations':
specifier: ^4.8.1
version: 4.8.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@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)
i18next: i18next:
specifier: ^26.3.1 specifier: ^26.3.1
version: 26.3.1(typescript@5.9.3) version: 26.3.1(typescript@5.9.3)
@ -769,67 +760,6 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} 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/localizations@4.8.1':
resolution: {integrity: sha512-DySY3KaVjiuKBY2zL08Ir0yDXzgdYmJkzX7y4FGITdlAH23wyIu9O9PKHjWulPjrtkwSIvi3TNmGY9x5NU+RgQ==}
engines: {node: '>=20.9.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/shared@4.17.1':
resolution: {integrity: sha512-9Ej2bLA7pWY1e07/PHmPNtQwiV1594rwacNYbppoDUPq9yRkBRZ+pDcpySkfpokS5YXvOUv6aFoPPbEMbQUVgw==}
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': '@commander-js/extra-typings@14.0.0':
resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==}
peerDependencies: peerDependencies:
@ -1663,9 +1593,6 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -1729,6 +1656,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcryptjs@2.4.6':
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@ -1910,6 +1840,9 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
body-parser@2.2.2: body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2016,9 +1949,6 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@ -2094,10 +2024,6 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2341,9 +2267,6 @@ packages:
fast-safe-stringify@2.1.1: fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} 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: fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
@ -2443,9 +2366,6 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
globby@16.1.0: globby@16.1.0:
resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==} resolution: {integrity: sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -2475,13 +2395,6 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} 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: human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -2564,14 +2477,13 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
joycon@3.1.1: joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} 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: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -3153,16 +3065,10 @@ packages:
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.1.0: std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
@ -3182,11 +3088,6 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'} 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: tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@ -3590,79 +3491,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@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/localizations@4.8.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@clerk/shared': 4.17.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
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/shared@4.17.1(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)': '@commander-js/extra-typings@14.0.0(commander@14.0.3)':
dependencies: dependencies:
commander: 14.0.3 commander: 14.0.3
@ -4620,8 +4448,6 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/merge-streams@4.0.0': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@tabby_ai/hijri-converter@1.0.5': {} '@tabby_ai/hijri-converter@1.0.5': {}
@ -4687,6 +4513,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@types/bcryptjs@2.4.6': {}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@ -4882,6 +4710,8 @@ snapshots:
baseline-browser-mapping@2.10.28: {} baseline-browser-mapping@2.10.28: {}
bcryptjs@2.4.3: {}
body-parser@2.2.2: body-parser@2.2.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@ -4990,8 +4820,6 @@ snapshots:
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.1.3: {}
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4: d3-array@3.2.4:
@ -5048,8 +4876,6 @@ snapshots:
depd@2.0.0: {} depd@2.0.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
@ -5223,8 +5049,6 @@ snapshots:
fast-safe-stringify@2.1.1: {} fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fast-uri@3.1.2: {} fast-uri@3.1.2: {}
fastq@1.20.1: fastq@1.20.1:
@ -5322,8 +5146,6 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
glob-to-regexp@0.4.1: {}
globby@16.1.0: globby@16.1.0:
dependencies: dependencies:
'@sindresorhus/merge-streams': 4.0.0 '@sindresorhus/merge-streams': 4.0.0
@ -5357,18 +5179,6 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 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: {} human-signals@8.0.1: {}
i18next-browser-languagedetector@8.2.1: i18next-browser-languagedetector@8.2.1:
@ -5420,9 +5230,9 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
joycon@3.1.1: {} jose@5.10.0: {}
js-cookie@3.0.7: {} joycon@3.1.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@ -6008,15 +5818,8 @@ snapshots:
stackback@0.0.2: {} stackback@0.0.2: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
statuses@2.0.2: {} statuses@2.0.2: {}
std-env@3.10.0: {}
std-env@4.1.0: {} std-env@4.1.0: {}
string-argv@0.3.2: {} string-argv@0.3.2: {}
@ -6029,12 +5832,6 @@ snapshots:
strip-json-comments@5.0.3: {} 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: {} tailwind-merge@3.5.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.3.0): tailwindcss-animate@1.0.7(tailwindcss@4.3.0):