diff --git a/backend/.env.example b/backend/.env.example index 61a0957..099dcf9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,9 +3,10 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db FRONTEND_URL=http://localhost:6767 +JWT_SECRET=change-this-secret-in-production-min-32-chars # Optional Feature Flags (all default to false for backward compatibility) # Set to "true" or "1" to enable: # ENABLE_PASSWORD_RESET=false # ENABLE_REFRESH_TOKEN_ROTATION=false -# ENABLE_AUDIT_LOGGING=false \ No newline at end of file +# ENABLE_AUDIT_LOGGING=false diff --git a/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql b/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql index 753f64e..6857241 100644 --- a/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql +++ b/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql @@ -1,21 +1,46 @@ -/* - Warnings: +-- NOTE: +-- This migration assigns all pre-existing data to a bootstrap admin user so that +-- upgrading an existing (non-empty) database doesn't fail and the data remains accessible. +-- The bootstrap admin user starts inactive and must be activated via the app's +-- initial registration flow. - - Added the required column `userId` to the `Collection` table without a default value. This is not possible if the table is not empty. - - Added the required column `userId` to the `Drawing` table without a default value. This is not possible if the table is not empty. +-- Constants +-- Keep in sync with backend/src/auth.ts +-- (SQLite doesn't support variables; we inline the values instead.) +-- BOOTSTRAP_USER_ID = 'bootstrap-admin' +-- BOOTSTRAP_LIBRARY_ID = 'user_bootstrap-admin' -*/ -- CreateTable CREATE TABLE "User" ( "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT, "email" TEXT NOT NULL, "passwordHash" TEXT NOT NULL, "name" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'USER', + "mustResetPassword" BOOLEAN NOT NULL DEFAULT false, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL ); +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default', + "registrationEnabled" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- Bootstrap state: +-- - Insert a singleton config row (registration disabled by default) +-- - Insert an inactive bootstrap admin user and assign all existing data to it +INSERT INTO "SystemConfig" ("id", "registrationEnabled", "createdAt", "updatedAt") +VALUES ('default', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +INSERT INTO "User" ("id", "username", "email", "passwordHash", "name", "role", "mustResetPassword", "isActive", "createdAt", "updatedAt") +VALUES ('bootstrap-admin', NULL, 'bootstrap@excalidash.local', '', 'Bootstrap Admin', 'ADMIN', true, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + -- RedefineTables PRAGMA defer_foreign_keys=ON; PRAGMA foreign_keys=OFF; @@ -27,7 +52,8 @@ CREATE TABLE "new_Collection" ( "updatedAt" DATETIME NOT NULL, CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); -INSERT INTO "new_Collection" ("createdAt", "id", "name", "updatedAt") SELECT "createdAt", "id", "name", "updatedAt" FROM "Collection"; +INSERT INTO "new_Collection" ("createdAt", "id", "name", "userId", "updatedAt") +SELECT "createdAt", "id", "name", 'bootstrap-admin', "updatedAt" FROM "Collection"; DROP TABLE "Collection"; ALTER TABLE "new_Collection" RENAME TO "Collection"; CREATE TABLE "new_Drawing" ( @@ -45,7 +71,8 @@ CREATE TABLE "new_Drawing" ( CONSTRAINT "Drawing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); -INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version") SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version" FROM "Drawing"; +INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "userId", "updatedAt", "version") +SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", 'bootstrap-admin', "updatedAt", "version" FROM "Drawing"; DROP TABLE "Drawing"; ALTER TABLE "new_Drawing" RENAME TO "Drawing"; CREATE TABLE "new_Library" ( @@ -54,7 +81,9 @@ CREATE TABLE "new_Library" ( "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL ); -INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt") SELECT "createdAt", "id", "items", "updatedAt" FROM "Library"; +-- Migrate the singleton library to the bootstrap user's library key. +INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt") +SELECT "createdAt", 'user_bootstrap-admin', "items", "updatedAt" FROM "Library" WHERE "id" = 'default'; DROP TABLE "Library"; ALTER TABLE "new_Library" RENAME TO "Library"; PRAGMA foreign_keys=ON; @@ -62,3 +91,6 @@ PRAGMA defer_foreign_keys=OFF; -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 467a0bb..247699a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -14,9 +14,12 @@ datasource db { model User { id String @id @default(uuid()) + username String? @unique email String @unique passwordHash String name String + role String @default("USER") + mustResetPassword Boolean @default(false) isActive Boolean @default(true) drawings Drawing[] collections Collection[] @@ -27,6 +30,13 @@ model User { updatedAt DateTime @updatedAt } +model SystemConfig { + id String @id @default("default") + registrationEnabled Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Collection { id String @id @default(uuid()) name String diff --git a/backend/src/__tests__/testUtils.ts b/backend/src/__tests__/testUtils.ts index b2555a3..715a511 100644 --- a/backend/src/__tests__/testUtils.ts +++ b/backend/src/__tests__/testUtils.ts @@ -2,11 +2,53 @@ * Test utilities for backend integration tests */ import { PrismaClient } from "../generated/client"; +import fs from "fs"; import path from "path"; import { execSync } from "child_process"; -// Use a separate test database -const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db"); +// Use a unique test database per test-file import to avoid cross-file contention +// when Vitest runs test files in parallel. +const TEST_DB_FILENAME = `test.${process.pid}.${Math.random().toString(16).slice(2)}.db`; +const TEST_DB_PATH = path.resolve(__dirname, "../../prisma", TEST_DB_FILENAME); +const DB_PUSH_LOCK_PATH = path.resolve(__dirname, "../../prisma/.test-db-push.lock"); + +const sleepSync = (ms: number) => { + const shared = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(shared, 0, 0, ms); +}; + +const withDbPushLock = (fn: () => void) => { + const start = Date.now(); + let fd: number | null = null; + while (fd === null) { + try { + fd = fs.openSync(DB_PUSH_LOCK_PATH, "wx"); + fs.writeFileSync(fd, String(process.pid)); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EEXIST") throw error; + if (Date.now() - start > 30_000) { + throw new Error("Timed out waiting for Prisma db push lock"); + } + sleepSync(50); + } + } + + try { + fn(); + } finally { + try { + fs.closeSync(fd); + } catch { + // ignore + } + try { + fs.unlinkSync(DB_PUSH_LOCK_PATH); + } catch { + // ignore + } + } +}; /** * Get a test Prisma client pointing to the test database @@ -32,10 +74,19 @@ export const setupTestDb = () => { // Run Prisma migrations to create the test database try { - execSync("npx prisma db push --skip-generate", { - cwd: path.resolve(__dirname, "../../"), - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: "pipe", + withDbPushLock(() => { + execSync("npx prisma db push --skip-generate --force-reset", { + cwd: path.resolve(__dirname, "../../"), + env: { + ...process.env, + DATABASE_URL: databaseUrl, + // Work around Prisma schema engine failures on this repo's schema + // (seen as a blank "Schema engine error:" from `prisma db push`). + // `RUST_LOG=info` reliably avoids the failure mode. + RUST_LOG: "info", + }, + stdio: "pipe", + }); }); } catch (error) { console.error("Failed to setup test database:", error); diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 534c7ff..a616739 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -8,7 +8,7 @@ import type { StringValue } from "ms"; import { z } from "zod"; import { PrismaClient } from "./generated/client"; import { config } from "./config"; -import { requireAuth } from "./middleware/auth"; +import { requireAuth, optionalAuth } from "./middleware/auth"; import { sanitizeText } from "./security"; import rateLimit from "express-rate-limit"; import { logAuditEvent } from "./utils/audit"; @@ -38,6 +38,17 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { const router = express.Router(); const prisma = new PrismaClient(); +const BOOTSTRAP_USER_ID = "bootstrap-admin"; +const DEFAULT_SYSTEM_CONFIG_ID = "default"; + +const ensureSystemConfig = async () => { + return prisma.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: {}, + create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: false }, + }); +}; + // Rate limiting for auth endpoints (stricter than general rate limiting) const authRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -52,16 +63,50 @@ const authRateLimiter = rateLimit({ // Validation schemas const registerSchema = z.object({ + username: z.string().trim().min(3).max(50).optional(), email: z.string().email().toLowerCase().trim(), password: z.string().min(8).max(100), name: z.string().trim().min(1).max(100), }); -const loginSchema = z.object({ - email: z.string().email().toLowerCase().trim(), - password: z.string(), +const loginSchema = z + .object({ + identifier: z.string().trim().min(1).max(255).optional(), + email: z.string().email().toLowerCase().trim().optional(), + username: z.string().trim().min(1).max(255).optional(), + password: z.string(), + }) + .refine((data) => Boolean(data.identifier || data.email || data.username), { + message: "identifier/email/username is required", + }); + +const registrationToggleSchema = z.object({ + enabled: z.boolean(), }); +const adminRoleUpdateSchema = z.object({ + identifier: z.string().trim().min(1).max(255), + role: z.enum(["ADMIN", "USER"]), +}); + +const findUserByIdentifier = async (identifier: string) => { + const trimmed = identifier.trim(); + if (trimmed.length === 0) return null; + + const looksLikeEmail = trimmed.includes("@"); + if (looksLikeEmail) { + return prisma.user.findUnique({ + where: { email: trimmed.toLowerCase() }, + }); + } + + return prisma.user.findFirst({ + where: { + OR: [{ username: trimmed }, { email: trimmed.toLowerCase() }], + }, + }); +}; + /** * Generate JWT tokens (access and refresh) * Note: expiresIn accepts string (like "15m", "7d") or number (seconds) @@ -105,7 +150,102 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => }); } - const { email, password, name } = parsed.data; + const { email, password, name, username } = parsed.data; + + const systemConfig = await ensureSystemConfig(); + + const activeUsers = await prisma.user.count({ where: { isActive: true } }); + const bootstrapUser = await prisma.user.findUnique({ + where: { id: BOOTSTRAP_USER_ID }, + select: { id: true, isActive: true }, + }); + const isBootstrapFlow = + Boolean(bootstrapUser) && + bootstrapUser?.isActive === false && + activeUsers === 0 && + bootstrapUser.id === BOOTSTRAP_USER_ID; + + // Bootstrap flow: first registration activates the bootstrap admin user + // created during migration and retains ownership of migrated data. + if (isBootstrapFlow) { + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + const sanitizedName = sanitizeText(name, 100); + + const user = await prisma.user.update({ + where: { id: BOOTSTRAP_USER_ID }, + data: { + email, + username: username ?? null, + passwordHash, + name: sanitizedName, + role: "ADMIN", + mustResetPassword: false, + isActive: true, + }, + select: { + id: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + }, + }); + + // Create trash collection if it doesn't exist (shared across all users) + const existingTrash = await prisma.collection.findUnique({ + where: { id: "trash" }, + }); + if (!existingTrash) { + await prisma.collection.create({ + data: { + id: "trash", + name: "Trash", + userId: user.id, // Shared, but pick a stable owner + }, + }); + } + + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + + if (config.enableRefreshTokenRotation) { + const expiresAt = new Date(); + expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + await prisma.refreshToken.create({ + data: { userId: user.id, token: refreshToken, expiresAt }, + }); + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "bootstrap_admin", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + return res.status(201).json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + }, + accessToken, + refreshToken, + registrationEnabled: systemConfig.registrationEnabled, + bootstrapped: true, + }); + } + + if (!systemConfig.registrationEnabled) { + return res.status(403).json({ + error: "Forbidden", + message: "User registration is disabled.", + }); + } // Check if user already exists const existingUser = await prisma.user.findUnique({ @@ -119,6 +259,19 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => }); } + if (username) { + const existingUsername = await prisma.user.findFirst({ + where: { username }, + select: { id: true }, + }); + if (existingUsername) { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } + } + // Hash password const saltRounds = 10; const passwordHash = await bcrypt.hash(password, saltRounds); @@ -132,11 +285,14 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => email, passwordHash, name: sanitizedName, + username: username ?? null, }, select: { id: true, email: true, name: true, + role: true, + mustResetPassword: true, createdAt: true, }, }); @@ -195,9 +351,12 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => id: user.id, email: user.email, name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, }, accessToken, refreshToken, + registrationEnabled: systemConfig.registrationEnabled, }); } catch (error) { console.error("Registration error:", error); @@ -223,12 +382,29 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => { }); } - const { email, password } = parsed.data; + const identifier = + parsed.data.email || + parsed.data.username || + parsed.data.identifier || + ""; + const { password } = parsed.data; - // Find user - const user = await prisma.user.findUnique({ - where: { email }, + // Block login until bootstrap is completed (so migrated data remains reachable) + const bootstrapUser = await prisma.user.findUnique({ + where: { id: BOOTSTRAP_USER_ID }, + select: { id: true, isActive: true }, }); + if (bootstrapUser && bootstrapUser.isActive === false) { + const activeUsers = await prisma.user.count({ where: { isActive: true } }); + if (activeUsers === 0) { + return res.status(409).json({ + error: "Bootstrap required", + message: "Initial admin account has not been configured yet. Register to bootstrap.", + }); + } + } + + const user = await findUserByIdentifier(identifier); if (!user) { // Don't reveal if user exists (prevent user enumeration) @@ -255,7 +431,7 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => { action: "login_failed", ipAddress: req.ip || req.connection.remoteAddress || undefined, userAgent: req.headers["user-agent"] || undefined, - details: { email }, + details: { identifier }, }); } @@ -304,6 +480,8 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => { id: user.id, email: user.email, name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, }, accessToken, refreshToken, @@ -467,8 +645,11 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => { where: { id: req.user.id }, select: { id: true, + username: true, email: true, name: true, + role: true, + mustResetPassword: true, createdAt: true, updatedAt: true, }, @@ -491,6 +672,124 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => { } }); +/** + * GET /auth/status + * Lightweight auth + registration status (supports bootstrap UX) + */ +router.get("/status", optionalAuth, async (req: Request, res: Response) => { + try { + const systemConfig = await ensureSystemConfig(); + const bootstrapUser = await prisma.user.findUnique({ + where: { id: BOOTSTRAP_USER_ID }, + select: { id: true, isActive: true }, + }); + + res.json({ + enabled: true, + authenticated: Boolean(req.user), + registrationEnabled: systemConfig.registrationEnabled, + bootstrapRequired: Boolean(bootstrapUser && bootstrapUser.isActive === false), + user: req.user + ? { + id: req.user.id, + username: req.user.username ?? null, + email: req.user.email, + name: req.user.name, + role: req.user.role, + mustResetPassword: req.user.mustResetPassword ?? false, + } + : null, + }); + } catch (error) { + console.error("Auth status error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to fetch auth status", + }); + } +}); + +/** + * POST /auth/registration/toggle + * Enable/disable registration (admin-only) + */ +router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.role !== "ADMIN") { + return res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + } + + const parsed = registrationToggleSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid toggle payload" }); + } + + const updated = await prisma.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: { registrationEnabled: parsed.data.enabled }, + create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: parsed.data.enabled }, + }); + + res.json({ registrationEnabled: updated.registrationEnabled }); + } catch (error) { + console.error("Registration toggle error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update registration setting", + }); + } +}); + +/** + * POST /auth/admins + * Promote/demote a user (admin-only) + */ +router.post("/admins", requireAuth, async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.role !== "ADMIN") { + return res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + } + + const parsed = adminRoleUpdateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid admin update payload" }); + } + + const target = await findUserByIdentifier(parsed.data.identifier); + if (!target) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + const updated = await prisma.user.update({ + where: { id: target.id }, + data: { role: parsed.data.role }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + + res.json({ user: updated }); + } catch (error) { + console.error("Admin role update error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update user role", + }); + } +}); + /** * POST /auth/password-reset-request * Request a password reset (sends reset token via email) @@ -562,7 +861,8 @@ router.post("/password-reset-request", authRateLimiter, async (req: Request, res // For now, we'll return the token in development (remove in production!) if (config.nodeEnv === "development") { console.log(`[DEV] Password reset token for ${email}: ${resetToken}`); - console.log(`[DEV] Reset URL: ${config.frontendUrl}/reset-password?token=${resetToken}`); + const baseUrl = config.frontendUrl || "http://localhost:6767"; + console.log(`[DEV] Reset URL: ${baseUrl}/reset-password?token=${resetToken}`); } } @@ -643,7 +943,7 @@ router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res // Update user password await prisma.user.update({ where: { id: resetToken.userId }, - data: { passwordHash }, + data: { passwordHash, mustResetPassword: false }, }); // Mark reset token as used @@ -811,7 +1111,7 @@ router.post("/change-password", requireAuth, authRateLimiter, async (req: Reques // Update password await prisma.user.update({ where: { id: user.id }, - data: { passwordHash }, + data: { passwordHash, mustResetPassword: false }, }); // Revoke all refresh tokens for this user (force re-login) - if rotation enabled @@ -852,4 +1152,4 @@ router.post("/change-password", requireAuth, authRateLimiter, async (req: Reques } }); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/config.ts b/backend/src/config.ts index 7f92e13..a03400b 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -2,14 +2,16 @@ * Configuration validation and environment variable management */ import dotenv from "dotenv"; +import crypto from "crypto"; +import path from "path"; dotenv.config(); interface Config { port: number; nodeEnv: string; - databaseUrl: string; - frontendUrl: string; + databaseUrl?: string; + frontendUrl?: string; jwtSecret: string; jwtAccessExpiresIn: string; jwtRefreshExpiresIn: string; @@ -34,6 +36,65 @@ const getOptionalEnv = (key: string, defaultValue: string): string => { return process.env[key] || defaultValue; }; +const resolveJwtSecret = (nodeEnv: string): string => { + const provided = process.env.JWT_SECRET; + if (provided && provided.trim().length > 0) { + return provided; + } + + if (nodeEnv === "production") { + throw new Error("Missing required environment variable: JWT_SECRET"); + } + + const generated = crypto.randomBytes(32).toString("hex"); + console.warn( + "[security] JWT_SECRET is not set (non-production). Using an ephemeral secret; tokens will be invalidated on restart." + ); + return generated; +}; + +const parseFrontendUrl = (raw: string | undefined): string | undefined => { + if (!raw || raw.trim().length === 0) return undefined; + const first = raw.split(",")[0]?.trim(); + if (!first) return undefined; + try { + // Validate basic format + new URL(/^https?:\/\//i.test(first) ? first : `http://${first}`); + } catch { + // Don't hard-fail; FRONTEND_URL supports multiple origins in other parts of the app. + return first; + } + return first; +}; + +const resolveDatabaseUrl = (rawUrl?: string) => { + const backendRoot = path.resolve(__dirname, "../"); + const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); + + if (!rawUrl || rawUrl.trim().length === 0) { + return `file:${defaultDbPath}`; + } + + if (!rawUrl.startsWith("file:")) { + return rawUrl; + } + + const filePath = rawUrl.replace(/^file:/, ""); + const prismaDir = path.resolve(backendRoot, "prisma"); + const normalizedRelative = filePath.replace(/^\.\/?/, ""); + const hasLeadingPrismaDir = + normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/"); + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); + + return `file:${absolutePath}`; +}; + +// Ensure DATABASE_URL is resolved before any PrismaClient is created. +process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); + const getOptionalBoolean = (key: string, defaultValue: boolean): boolean => { const value = process.env[key]; if (!value) return defaultValue; @@ -53,9 +114,9 @@ const getRequiredEnvNumber = (key: string, defaultValue: number): number => { export const config: Config = { port: getRequiredEnvNumber("PORT", 8000), nodeEnv: getOptionalEnv("NODE_ENV", "development"), - databaseUrl: getRequiredEnv("DATABASE_URL"), - frontendUrl: getOptionalEnv("FRONTEND_URL", "http://localhost:6767"), - jwtSecret: getRequiredEnv("JWT_SECRET"), + databaseUrl: process.env.DATABASE_URL, + frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL), + jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")), jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"), jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"), rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000), @@ -77,11 +138,4 @@ if (config.nodeEnv === "production") { } } -// Validate frontend URL format -try { - new URL(config.frontendUrl); -} catch { - throw new Error(`Invalid FRONTEND_URL format: ${config.frontendUrl}`); -} - -console.log("Configuration validated successfully"); \ No newline at end of file +console.log("Configuration validated successfully"); diff --git a/backend/src/index.ts b/backend/src/index.ts index aae2c27..7be537d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -33,37 +33,6 @@ import { logAuditEvent } from "./utils/audit"; const backendRoot = path.resolve(__dirname, "../"); const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); -const resolveDatabaseUrl = (rawUrl?: string) => { - if (!rawUrl || rawUrl.trim().length === 0) { - return `file:${defaultDbPath}`; - } - - if (!rawUrl.startsWith("file:")) { - return rawUrl; - } - - const filePath = rawUrl.replace(/^file:/, ""); - - // Prisma treats relative SQLite paths as relative to the schema directory - // (i.e. `backend/prisma/schema.prisma`). Historically this project used - // `file:./prisma/dev.db`, which Prisma interprets as `prisma/prisma/dev.db`. - // To keep runtime and migrations aligned: - // - Prefer resolving relative paths against `backend/prisma` - // - But if the path already includes a leading `prisma/`, resolve from repo root - const prismaDir = path.resolve(backendRoot, "prisma"); - const normalizedRelative = filePath.replace(/^\.\/?/, ""); - const hasLeadingPrismaDir = - normalizedRelative === "prisma" || - normalizedRelative.startsWith("prisma/"); - - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); - - return `file:${absolutePath}`; -}; - -process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL); // Helper to get the resolved database file path diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index b7dbe35..bb8b0ea 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -14,8 +14,11 @@ declare global { interface Request { user?: { id: string; + username?: string | null; email: string; name: string; + role: string; + mustResetPassword?: boolean; }; } } @@ -108,7 +111,15 @@ export const requireAuth = async ( try { const user = await prisma.user.findUnique({ where: { id: payload.userId }, - select: { id: true, email: true, name: true, isActive: true }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, }); if (!user || !user.isActive) { @@ -122,8 +133,11 @@ export const requireAuth = async ( // Attach user to request req.user = { id: user.id, + username: user.username, email: user.email, name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, }; next(); @@ -160,14 +174,25 @@ export const optionalAuth = async ( try { const user = await prisma.user.findUnique({ where: { id: payload.userId }, - select: { id: true, email: true, name: true, isActive: true }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, }); if (user && user.isActive) { req.user = { id: user.id, + username: user.username, email: user.email, name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, }; } } catch (error) { @@ -176,4 +201,4 @@ export const optionalAuth = async ( } next(); -}; \ No newline at end of file +}; diff --git a/backend/src/utils/audit.ts b/backend/src/utils/audit.ts index ae3ac32..dfc3c09 100644 --- a/backend/src/utils/audit.ts +++ b/backend/src/utils/audit.ts @@ -3,7 +3,12 @@ */ import { PrismaClient } from "../generated/client"; -const prisma = new PrismaClient(); +let prisma: PrismaClient | null = null; +const getPrisma = () => { + if (prisma) return prisma; + prisma = new PrismaClient(); + return prisma; +}; export interface AuditLogData { userId?: string; @@ -27,7 +32,7 @@ export const logAuditEvent = async (data: AuditLogData): Promise => { return; // Feature disabled, silently skip } - await prisma.auditLog.create({ + await getPrisma().auditLog.create({ data: { userId: data.userId || null, action: data.action, @@ -62,7 +67,7 @@ export const getAuditLogs = async ( return []; // Feature disabled, return empty array } - const logs = await prisma.auditLog.findMany({ + const logs = await getPrisma().auditLog.findMany({ where: userId ? { userId } : undefined, orderBy: { createdAt: "desc" }, take: limit, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b65db66..4a531d8 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -69,13 +69,6 @@ export const clearCsrfToken = (): void => { // Add request interceptor to include JWT and CSRF tokens api.interceptors.request.use( async (config) => { - // Auth endpoints that require authentication (need JWT token) - const authenticatedAuthEndpoints = [ - '/auth/me', - '/auth/profile', - '/auth/change-password', - ]; - // Auth endpoints that don't require authentication (login, register, etc.) const publicAuthEndpoints = [ '/auth/login', @@ -85,9 +78,7 @@ api.interceptors.request.use( '/auth/password-reset-confirm', ]; - const isAuthenticatedAuthEndpoint = config.url && authenticatedAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint)); const isPublicAuthEndpoint = config.url && publicAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint)); - const isAuthEndpoint = config.url?.startsWith('/auth/'); // Add JWT token to all requests except public auth endpoints if (!isPublicAuthEndpoint) { diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 3faf477..6a8f921 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,4 +1,5 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useEffect } from 'react'; +import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; @@ -6,8 +7,11 @@ const API_URL = import.meta.env.VITE_API_URL || "/api"; interface User { id: string; + username?: string | null; email: string; name: string; + role?: "ADMIN" | "USER" | string; + mustResetPassword?: boolean; } interface AuthContextType { @@ -187,4 +191,4 @@ export const useAuth = () => { throw new Error('useAuth must be used within an AuthProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 5f2f9e1..8c645d5 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -4,16 +4,18 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import * as api from '../api'; import type { Collection } from '../types'; -import { User, Lock, Save, X } from 'lucide-react'; -import { ConfirmModal } from '../components/ConfirmModal'; +import { User, Lock, Save, X, Shield } from 'lucide-react'; export const Profile: React.FC = () => { const { user: authUser, logout } = useAuth(); const navigate = useNavigate(); + const isAdmin = authUser?.role === 'ADMIN'; const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); + const [registrationEnabled, setRegistrationEnabled] = useState(null); + const [registrationLoading, setRegistrationLoading] = useState(false); // User info state const [name, setName] = useState(''); @@ -36,12 +38,47 @@ export const Profile: React.FC = () => { setName(authUser.name); setEmail(authUser.email); } + + if (isAdmin) { + const statusResponse = await api.api.get<{ registrationEnabled: boolean }>('/auth/status'); + setRegistrationEnabled(statusResponse.data.registrationEnabled); + } else { + setRegistrationEnabled(null); + } } catch (err) { console.error('Failed to fetch data:', err); } }; fetchData(); - }, [authUser]); + }, [authUser, isAdmin]); + + const handleToggleRegistration = async () => { + if (!isAdmin || registrationEnabled === null) return; + + setRegistrationLoading(true); + setError(''); + setSuccess(''); + + try { + const response = await api.api.post<{ registrationEnabled: boolean }>('/auth/registration/toggle', { + enabled: !registrationEnabled, + }); + setRegistrationEnabled(response.data.registrationEnabled); + setSuccess(response.data.registrationEnabled ? 'Registration enabled' : 'Registration disabled'); + } catch (err: unknown) { + let message = 'Failed to update registration setting'; + if (api.isAxiosError(err)) { + if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.response?.data?.error) { + message = err.response.data.error; + } + } + setError(message); + } finally { + setRegistrationLoading(false); + } + }; const handleSelectCollection = (id: string | null | undefined) => { if (id === undefined) navigate('/'); @@ -226,6 +263,42 @@ export const Profile: React.FC = () => { + {/* Admin Settings */} + {isAdmin && ( +
+
+
+ +
+

Admin Settings

+
+ +
+
+

User registration

+

+ {registrationEnabled === null + ? 'Loading…' + : registrationEnabled + ? 'New users can create accounts.' + : 'Registration is disabled.'} +

+
+ +
+
+ )} + {/* Password Change Section */}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5ac798e..1b33d8e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -94,7 +94,7 @@ export const Settings: React.FC = () => {