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:
parent
9648b8553c
commit
441c828a17
20 changed files with 351 additions and 629 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
20
.agents/memory/custom-jwt-auth.md
Normal file
20
.agents/memory/custom-jwt-auth.md
Normal 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)
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
27
artifacts/api-server/src/lib/seedAdmin.ts
Normal file
27
artifacts/api-server/src/lib/seedAdmin.ts
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
94
artifacts/skillguard/src/pages/login.tsx
Normal file
94
artifacts/skillguard/src/pages/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
lib/db/src/schema/adminUsers.ts
Normal file
11
lib/db/src/schema/adminUsers.ts
Normal 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;
|
||||||
|
|
@ -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
251
pnpm-lock.yaml
generated
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue