From 441c828a179c02df3107430b62e4b508e32da23b Mon Sep 17 00:00:00 2001 From: amertensreplit <49614208-amertensreplit@users.noreply.replit.com> Date: Tue, 16 Jun 2026 21:22:55 +0000 Subject: [PATCH] Replace Clerk with custom email+password authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/memory/MEMORY.md | 2 +- .agents/memory/custom-jwt-auth.md | 20 ++ artifacts/api-server/package.json | 6 +- artifacts/api-server/src/app.ts | 25 +- artifacts/api-server/src/index.ts | 2 + artifacts/api-server/src/lib/seedAdmin.ts | 27 ++ artifacts/api-server/src/middlewares/auth.ts | 58 ++-- .../src/middlewares/clerkProxyMiddleware.ts | 91 ------ artifacts/api-server/src/routes/auth.ts | 43 ++- artifacts/skillguard/package.json | 3 - artifacts/skillguard/src/App.tsx | 309 ++++-------------- .../skillguard/src/components/layout.tsx | 24 +- .../src/components/require-admin.tsx | 7 +- .../skillguard/src/i18n/locales/de/common.ts | 2 + .../skillguard/src/i18n/locales/en/common.ts | 2 + .../skillguard/src/i18n/locales/es/common.ts | 2 + artifacts/skillguard/src/pages/login.tsx | 94 ++++++ lib/db/src/schema/adminUsers.ts | 11 + lib/db/src/schema/index.ts | 1 + pnpm-lock.yaml | 251 ++------------ 20 files changed, 351 insertions(+), 629 deletions(-) create mode 100644 .agents/memory/custom-jwt-auth.md create mode 100644 artifacts/api-server/src/lib/seedAdmin.ts delete mode 100644 artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts create mode 100644 artifacts/skillguard/src/pages/login.tsx create mode 100644 lib/db/src/schema/adminUsers.ts diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index f91b607..a130280 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -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:/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. +- [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. diff --git a/.agents/memory/custom-jwt-auth.md b/.agents/memory/custom-jwt-auth.md new file mode 100644 index 0000000..6e6d0c5 --- /dev/null +++ b/.agents/memory/custom-jwt-auth.md @@ -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) diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index ef084a9..d65e9de 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -11,21 +11,21 @@ "test": "vitest run" }, "dependencies": { - "@clerk/express": "^2.1.23", - "@clerk/shared": "^4.15.0", "@workspace/api-zod": "workspace:*", "@workspace/db": "workspace:*", + "bcryptjs": "^2.4.3", "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", + "jose": "^5.10.0", "pino": "^9.14.0", "pino-http": "^10.5.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index ffade8a..6ef8b72 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -1,13 +1,7 @@ import express, { type Express } from "express"; import cors from "cors"; +import cookieParser from "cookie-parser"; 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"; @@ -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(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(cookieParser()); app.use("/api", router); diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index d8f5c74..912d453 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -1,6 +1,7 @@ import app from "./app"; import { logger } from "./lib/logger"; import { seedDefaults } from "./lib/seed"; +import { seedAdminUser } from "./lib/seedAdmin"; const rawPort = process.env["PORT"]; @@ -24,4 +25,5 @@ app.listen(port, (err) => { logger.info({ port }, "Server listening"); void seedDefaults(); + void seedAdminUser(); }); diff --git a/artifacts/api-server/src/lib/seedAdmin.ts b/artifacts/api-server/src/lib/seedAdmin.ts new file mode 100644 index 0000000..ef33382 --- /dev/null +++ b/artifacts/api-server/src/lib/seedAdmin.ts @@ -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 { + 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."); + } +} diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts index 349f86b..dcd44be 100644 --- a/artifacts/api-server/src/middlewares/auth.ts +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -1,50 +1,44 @@ import type { Request, Response, NextFunction } from "express"; -import { getAuth, clerkClient } from "@clerk/express"; +import * as jose from "jose"; 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. - */ +function getSecret(): Uint8Array { + const secret = process.env.SESSION_SECRET; + if (!secret) throw new Error("SESSION_SECRET is not set"); + return new TextEncoder().encode(secret); +} + 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 }; + const token = req.cookies?.session; + if (!token) 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; + const { payload } = await jose.jwtVerify(token, getSecret(), { + algorithms: ["HS256"], + }); + const email = typeof payload.email === "string" ? payload.email : 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) { - logger.error({ err }, "Clerk-Benutzer konnte nicht geladen werden"); - email = null; + logger.debug({ err }, "JWT verification failed"); + return { userId: null, email: null, isAdmin: false }; } +} - const allowlist = getAdminAllowlist(); - const isAdmin = !!email && allowlist.includes(email.toLowerCase()); - return { userId, email, isAdmin }; +export async function signToken(userId: number, email: string): Promise { + return new jose.SignJWT({ email }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(String(userId)) + .setIssuedAt() + .setExpirationTime("30d") + .sign(getSecret()); } declare global { diff --git a/artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts b/artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts deleted file mode 100644 index 11834b8..0000000 --- a/artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts +++ /dev/null @@ -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; -} diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 8934d28..00b96fc 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -1,9 +1,15 @@ 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 { resolveAuth } from "../middlewares/auth"; +import { resolveAuth, signToken } from "../middlewares/auth"; const router: IRouter = Router(); +const COOKIE_NAME = "session"; +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; + router.get("/me", async (req, res) => { const info = await resolveAuth(req); 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; diff --git a/artifacts/skillguard/package.json b/artifacts/skillguard/package.json index e92d784..293d439 100644 --- a/artifacts/skillguard/package.json +++ b/artifacts/skillguard/package.json @@ -75,9 +75,6 @@ "zod": "catalog:" }, "dependencies": { - "@clerk/localizations": "^4.8.1", - "@clerk/react": "^6.7.3", - "@clerk/themes": "^2.4.57", "i18next": "^26.3.1", "i18next-browser-languagedetector": "^8.2.1", "react-i18next": "^17.0.8" diff --git a/artifacts/skillguard/src/App.tsx b/artifacts/skillguard/src/App.tsx index 0cfe271..43b324c 100644 --- a/artifacts/skillguard/src/App.tsx +++ b/artifacts/skillguard/src/App.tsx @@ -1,18 +1,12 @@ -import { useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -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 { Switch, Route, Router as WouterRouter } from "wouter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { PublicLayout } from "@/components/public-layout"; import { AppLayout } from "@/components/layout"; import { RequireAdmin } from "@/components/require-admin"; import NotFound from "@/pages/not-found"; +import LoginPage from "@/pages/login"; import Catalog from "@/pages/catalog"; import Dashboard from "@/pages/dashboard"; @@ -24,248 +18,89 @@ import Admin from "@/pages/admin"; import Impressum from "@/pages/impressum"; 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(/\/$/, ""); -// 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 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() { +function AppRoutes() { return ( -
- -
- ); -} + + + + {/* Public area */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -function SignUpPage() { - return ( -
- -
- ); -} + {/* Auth */} + -// Keeps the cache fresh when the signed-in user changes. -function ClerkQueryClientCacheInvalidator() { - const { addListener } = useClerk(); - const qc = useQueryClient(); - const prevUserIdRef = useRef(undefined); + {/* Admin back office */} + + + + + + + + + + + + + + + + + + + + + - 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; -} - -const CLERK_BASE_LOCALIZATIONS: Record = { - 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 ( - 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 e2d2e84..3a14f95 100644 --- a/artifacts/skillguard/src/components/layout.tsx +++ b/artifacts/skillguard/src/components/layout.tsx @@ -1,17 +1,27 @@ import { Link, useLocation } from "wouter"; import { useTranslation } from "react-i18next"; 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 { LanguageSwitcher } from "@/components/language-switcher"; const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); export function AppLayout({ children }: { children: React.ReactNode }) { - const [location] = useLocation(); - const { signOut } = useClerk(); - const { user } = useUser(); + const [location, setLocation] = useLocation(); 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 ( @@ -75,14 +85,14 @@ export function AppLayout({ children }: { children: React.ReactNode }) { - {user && ( + {me?.email && (
- {user.primaryEmailAddress?.emailAddress ?? t("common.adminLayout.signedIn")} + {me.email}
)} - signOut({ redirectUrl: basePath || "/" })}> + {t("common.adminLayout.signOut")} diff --git a/artifacts/skillguard/src/components/require-admin.tsx b/artifacts/skillguard/src/components/require-admin.tsx index 7b62889..7c4be50 100644 --- a/artifacts/skillguard/src/components/require-admin.tsx +++ b/artifacts/skillguard/src/components/require-admin.tsx @@ -5,10 +5,9 @@ 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. + * Gates admin-only screens. Reads GET /api/me (httpOnly cookie session) to + * decide access: unauthenticated visitors are invited to sign in, and only + * authenticated admins see the wrapped content. */ export function RequireAdmin({ children }: { children: React.ReactNode }) { const { data, isLoading } = useGetMe(); diff --git a/artifacts/skillguard/src/i18n/locales/de/common.ts b/artifacts/skillguard/src/i18n/locales/de/common.ts index dadcc7c..530e0b4 100644 --- a/artifacts/skillguard/src/i18n/locales/de/common.ts +++ b/artifacts/skillguard/src/i18n/locales/de/common.ts @@ -50,6 +50,8 @@ export default { signInSubtitle: "Melden Sie sich an, um den Administrationsbereich zu öffnen.", signUpTitle: "Konto erstellen", signUpSubtitle: "Registrieren Sie sich für den Administrationsbereich.", + signInButton: "Anmelden", + loginError: "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.", }, adminLayout: { subtitle: "Administration", diff --git a/artifacts/skillguard/src/i18n/locales/en/common.ts b/artifacts/skillguard/src/i18n/locales/en/common.ts index 7373e9a..17b31ca 100644 --- a/artifacts/skillguard/src/i18n/locales/en/common.ts +++ b/artifacts/skillguard/src/i18n/locales/en/common.ts @@ -50,6 +50,8 @@ export default { signInSubtitle: "Sign in to open the administration area.", signUpTitle: "Create account", signUpSubtitle: "Register for the administration area.", + signInButton: "Sign in", + loginError: "Login failed. Please check your credentials.", }, adminLayout: { subtitle: "Administration", diff --git a/artifacts/skillguard/src/i18n/locales/es/common.ts b/artifacts/skillguard/src/i18n/locales/es/common.ts index 2df3696..068e0f5 100644 --- a/artifacts/skillguard/src/i18n/locales/es/common.ts +++ b/artifacts/skillguard/src/i18n/locales/es/common.ts @@ -50,6 +50,8 @@ export default { signInSubtitle: "Inicie sesión para abrir el área de administración.", signUpTitle: "Crear cuenta", 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: { subtitle: "Administración", diff --git a/artifacts/skillguard/src/pages/login.tsx b/artifacts/skillguard/src/pages/login.tsx new file mode 100644 index 0000000..9c61a7a --- /dev/null +++ b/artifacts/skillguard/src/pages/login.tsx @@ -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(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 ( +
+ + +
+ +
+ {t("common.auth.signInTitle")} + {t("common.auth.signInSubtitle")} +
+ +
+
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error && ( +

{error}

+ )} + +
+
+
+
+ ); +} diff --git a/lib/db/src/schema/adminUsers.ts b/lib/db/src/schema/adminUsers.ts new file mode 100644 index 0000000..fff7cb6 --- /dev/null +++ b/lib/db/src/schema/adminUsers.ts @@ -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; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 3b578b9..8d7834b 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -4,3 +4,4 @@ export * from "./findings"; export * from "./aiProviders"; export * from "./prompts"; export * from "./rules"; +export * from "./adminUsers"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84810f4..43340b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,18 +166,15 @@ 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 '@workspace/db': specifier: workspace:* version: link:../../lib/db + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 cookie-parser: specifier: ^1.4.7 version: 1.4.7 @@ -196,9 +193,9 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 - http-proxy-middleware: - specifier: ^4.1.0 - version: 4.1.0 + jose: + specifier: ^5.10.0 + version: 5.10.0 pino: specifier: ^9.14.0 version: 9.14.0 @@ -206,6 +203,9 @@ importers: specifier: ^10.5.0 version: 10.5.0 devDependencies: + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 '@types/cookie-parser': specifier: ^1.4.10 version: 1.4.10(@types/express@5.0.6) @@ -419,15 +419,6 @@ importers: artifacts/skillguard: 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: specifier: ^26.3.1 version: 26.3.1(typescript@5.9.3) @@ -769,67 +760,6 @@ 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/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': resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} peerDependencies: @@ -1663,9 +1593,6 @@ 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==} @@ -1729,6 +1656,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1910,6 +1840,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -2016,9 +1949,6 @@ 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==} @@ -2094,10 +2024,6 @@ 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'} @@ -2341,9 +2267,6 @@ 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==} @@ -2443,9 +2366,6 @@ 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'} @@ -2475,13 +2395,6 @@ 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'} @@ -2564,14 +2477,13 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + joycon@3.1.1: 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==} @@ -3153,16 +3065,10 @@ 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==} @@ -3182,11 +3088,6 @@ 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==} @@ -3590,79 +3491,6 @@ 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/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)': dependencies: commander: 14.0.3 @@ -4620,8 +4448,6 @@ 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': {} @@ -4687,6 +4513,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcryptjs@2.4.6': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -4882,6 +4710,8 @@ snapshots: baseline-browser-mapping@2.10.28: {} + bcryptjs@2.4.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -4990,8 +4820,6 @@ snapshots: cssesc@3.0.0: {} - csstype@3.1.3: {} - csstype@3.2.3: {} d3-array@3.2.4: @@ -5048,8 +4876,6 @@ snapshots: depd@2.0.0: {} - dequal@2.0.3: {} - detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -5223,8 +5049,6 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-sha256@1.3.0: {} - fast-uri@3.1.2: {} fastq@1.20.1: @@ -5322,8 +5146,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} - globby@16.1.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -5357,18 +5179,6 @@ 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: {} i18next-browser-languagedetector@8.2.1: @@ -5420,9 +5230,9 @@ snapshots: 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: {} @@ -6008,15 +5818,8 @@ 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: {} @@ -6029,12 +5832,6 @@ 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):