feat(auth): consolidate multi-user auth and admin controls

This commit is contained in:
Zimeng Xiong
2026-02-06 00:25:13 -08:00
parent 700e153740
commit 75a1f11a96
13 changed files with 612 additions and 97 deletions
+2 -1
View File
@@ -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
# ENABLE_AUDIT_LOGGING=false
@@ -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");
+10
View File
@@ -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
+57 -6
View File
@@ -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);
+314 -14
View File
@@ -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;
export default router;
+67 -13
View File
@@ -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");
console.log("Configuration validated successfully");
-31
View File
@@ -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
+28 -3
View File
@@ -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();
};
};
+8 -3
View File
@@ -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<void> => {
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,
-9
View File
@@ -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) {
+6 -2
View File
@@ -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;
};
};
+76 -3
View File
@@ -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<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(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 = () => {
</div>
</div>
{/* Admin Settings */}
{isAdmin && (
<div className="bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-slate-50 dark:bg-neutral-800 rounded-xl flex items-center justify-center border-2 border-slate-200 dark:border-neutral-700">
<Shield size={24} className="text-slate-700 dark:text-neutral-200" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Admin Settings</h2>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-slate-900 dark:text-white font-bold">User registration</p>
<p className="text-sm text-slate-600 dark:text-neutral-400">
{registrationEnabled === null
? 'Loading…'
: registrationEnabled
? 'New users can create accounts.'
: 'Registration is disabled.'}
</p>
</div>
<button
onClick={handleToggleRegistration}
disabled={registrationLoading || registrationEnabled === null}
className="px-5 py-3 bg-slate-900 dark:bg-neutral-100 text-white dark:text-neutral-900 font-bold rounded-xl border-2 border-black dark:border-neutral-300 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
>
{registrationLoading
? 'Saving…'
: registrationEnabled
? 'Disable'
: 'Enable'}
</button>
</div>
</div>
)}
{/* Password Change Section */}
<div className="bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] p-6">
<div className="flex items-center justify-between mb-6">
+4 -4
View File
@@ -94,7 +94,7 @@ export const Settings: React.FC = () => {
<button
onClick={async () => {
try {
const response = await api.get('/export', { responseType: 'blob' });
const response = await api.api.get('/export', { responseType: 'blob' });
const blob = new Blob([response.data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -123,7 +123,7 @@ export const Settings: React.FC = () => {
<button
onClick={async () => {
try {
const response = await api.get('/export?format=db', { responseType: 'blob' });
const response = await api.api.get('/export?format=db', { responseType: 'blob' });
const blob = new Blob([response.data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -135,7 +135,7 @@ export const Settings: React.FC = () => {
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Export failed:', error);
if (error && typeof error === 'object' && 'response' in error && error.response?.status === 403) {
if (api.isAxiosError(error) && error.response?.status === 403) {
alert('Database export is not available. Please use JSON export instead.');
} else {
alert('Failed to export data. Please try again.');
@@ -156,7 +156,7 @@ export const Settings: React.FC = () => {
<button
onClick={async () => {
try {
const response = await api.get('/export/json', { responseType: 'blob' });
const response = await api.api.get('/export/json', { responseType: 'blob' });
const blob = new Blob([response.data], { type: 'application/zip' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');