feat(auth): consolidate multi-user auth and admin controls
This commit is contained in:
@@ -3,6 +3,7 @@ PORT=8000
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
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)
|
# Optional Feature Flags (all default to false for backward compatibility)
|
||||||
# Set to "true" or "1" to enable:
|
# Set to "true" or "1" to enable:
|
||||||
|
|||||||
@@ -1,21 +1,46 @@
|
|||||||
/*
|
-- NOTE:
|
||||||
Warnings:
|
-- 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.
|
-- Constants
|
||||||
- Added the required column `userId` to the `Drawing` table without a default value. This is not possible if the table is not empty.
|
-- 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
|
-- CreateTable
|
||||||
CREATE TABLE "User" (
|
CREATE TABLE "User" (
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"username" TEXT,
|
||||||
"email" TEXT NOT NULL,
|
"email" TEXT NOT NULL,
|
||||||
"passwordHash" TEXT NOT NULL,
|
"passwordHash" TEXT NOT NULL,
|
||||||
"name" 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,
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" DATETIME NOT NULL
|
"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
|
-- RedefineTables
|
||||||
PRAGMA defer_foreign_keys=ON;
|
PRAGMA defer_foreign_keys=ON;
|
||||||
PRAGMA foreign_keys=OFF;
|
PRAGMA foreign_keys=OFF;
|
||||||
@@ -27,7 +52,8 @@ CREATE TABLE "new_Collection" (
|
|||||||
"updatedAt" DATETIME NOT NULL,
|
"updatedAt" DATETIME NOT NULL,
|
||||||
CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
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";
|
DROP TABLE "Collection";
|
||||||
ALTER TABLE "new_Collection" RENAME TO "Collection";
|
ALTER TABLE "new_Collection" RENAME TO "Collection";
|
||||||
CREATE TABLE "new_Drawing" (
|
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_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
|
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";
|
DROP TABLE "Drawing";
|
||||||
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
|
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
|
||||||
CREATE TABLE "new_Library" (
|
CREATE TABLE "new_Library" (
|
||||||
@@ -54,7 +81,9 @@ CREATE TABLE "new_Library" (
|
|||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" DATETIME NOT NULL
|
"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";
|
DROP TABLE "Library";
|
||||||
ALTER TABLE "new_Library" RENAME TO "Library";
|
ALTER TABLE "new_Library" RENAME TO "Library";
|
||||||
PRAGMA foreign_keys=ON;
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -62,3 +91,6 @@ PRAGMA defer_foreign_keys=OFF;
|
|||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ datasource db {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
username String? @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
name String
|
name String
|
||||||
|
role String @default("USER")
|
||||||
|
mustResetPassword Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
collections Collection[]
|
collections Collection[]
|
||||||
@@ -27,6 +30,13 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default("default")
|
||||||
|
registrationEnabled Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model Collection {
|
model Collection {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -2,11 +2,53 @@
|
|||||||
* Test utilities for backend integration tests
|
* Test utilities for backend integration tests
|
||||||
*/
|
*/
|
||||||
import { PrismaClient } from "../generated/client";
|
import { PrismaClient } from "../generated/client";
|
||||||
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
// Use a separate test database
|
// Use a unique test database per test-file import to avoid cross-file contention
|
||||||
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
|
// 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
|
* 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
|
// Run Prisma migrations to create the test database
|
||||||
try {
|
try {
|
||||||
execSync("npx prisma db push --skip-generate", {
|
withDbPushLock(() => {
|
||||||
cwd: path.resolve(__dirname, "../../"),
|
execSync("npx prisma db push --skip-generate --force-reset", {
|
||||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
cwd: path.resolve(__dirname, "../../"),
|
||||||
stdio: "pipe",
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to setup test database:", error);
|
console.error("Failed to setup test database:", error);
|
||||||
|
|||||||
+313
-13
@@ -8,7 +8,7 @@ import type { StringValue } from "ms";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrismaClient } from "./generated/client";
|
import { PrismaClient } from "./generated/client";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import { requireAuth } from "./middleware/auth";
|
import { requireAuth, optionalAuth } from "./middleware/auth";
|
||||||
import { sanitizeText } from "./security";
|
import { sanitizeText } from "./security";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
import { logAuditEvent } from "./utils/audit";
|
import { logAuditEvent } from "./utils/audit";
|
||||||
@@ -38,6 +38,17 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
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)
|
// Rate limiting for auth endpoints (stricter than general rate limiting)
|
||||||
const authRateLimiter = rateLimit({
|
const authRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
@@ -52,16 +63,50 @@ const authRateLimiter = rateLimit({
|
|||||||
|
|
||||||
// Validation schemas
|
// Validation schemas
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
|
username: z.string().trim().min(3).max(50).optional(),
|
||||||
email: z.string().email().toLowerCase().trim(),
|
email: z.string().email().toLowerCase().trim(),
|
||||||
password: z.string().min(8).max(100),
|
password: z.string().min(8).max(100),
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z
|
||||||
email: z.string().email().toLowerCase().trim(),
|
.object({
|
||||||
password: z.string(),
|
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)
|
* Generate JWT tokens (access and refresh)
|
||||||
* Note: expiresIn accepts string (like "15m", "7d") or number (seconds)
|
* 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
|
// Check if user already exists
|
||||||
const existingUser = await prisma.user.findUnique({
|
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
|
// Hash password
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||||
@@ -132,11 +285,14 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
|
|||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
name: sanitizedName,
|
name: sanitizedName,
|
||||||
|
username: username ?? null,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
role: true,
|
||||||
|
mustResetPassword: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -195,9 +351,12 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
mustResetPassword: user.mustResetPassword,
|
||||||
},
|
},
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
registrationEnabled: systemConfig.registrationEnabled,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Registration error:", 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
|
// Block login until bootstrap is completed (so migrated data remains reachable)
|
||||||
const user = await prisma.user.findUnique({
|
const bootstrapUser = await prisma.user.findUnique({
|
||||||
where: { email },
|
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) {
|
if (!user) {
|
||||||
// Don't reveal if user exists (prevent user enumeration)
|
// 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",
|
action: "login_failed",
|
||||||
ipAddress: req.ip || req.connection.remoteAddress || undefined,
|
ipAddress: req.ip || req.connection.remoteAddress || undefined,
|
||||||
userAgent: req.headers["user-agent"] || 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,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
mustResetPassword: user.mustResetPassword,
|
||||||
},
|
},
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -467,8 +645,11 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => {
|
|||||||
where: { id: req.user.id },
|
where: { id: req.user.id },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
role: true,
|
||||||
|
mustResetPassword: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: 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
|
* POST /auth/password-reset-request
|
||||||
* Request a password reset (sends reset token via email)
|
* 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!)
|
// For now, we'll return the token in development (remove in production!)
|
||||||
if (config.nodeEnv === "development") {
|
if (config.nodeEnv === "development") {
|
||||||
console.log(`[DEV] Password reset token for ${email}: ${resetToken}`);
|
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
|
// Update user password
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: resetToken.userId },
|
where: { id: resetToken.userId },
|
||||||
data: { passwordHash },
|
data: { passwordHash, mustResetPassword: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark reset token as used
|
// Mark reset token as used
|
||||||
@@ -811,7 +1111,7 @@ router.post("/change-password", requireAuth, authRateLimiter, async (req: Reques
|
|||||||
// Update password
|
// Update password
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { passwordHash },
|
data: { passwordHash, mustResetPassword: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revoke all refresh tokens for this user (force re-login) - if rotation enabled
|
// Revoke all refresh tokens for this user (force re-login) - if rotation enabled
|
||||||
|
|||||||
+66
-12
@@ -2,14 +2,16 @@
|
|||||||
* Configuration validation and environment variable management
|
* Configuration validation and environment variable management
|
||||||
*/
|
*/
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
nodeEnv: string;
|
nodeEnv: string;
|
||||||
databaseUrl: string;
|
databaseUrl?: string;
|
||||||
frontendUrl: string;
|
frontendUrl?: string;
|
||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
jwtAccessExpiresIn: string;
|
jwtAccessExpiresIn: string;
|
||||||
jwtRefreshExpiresIn: string;
|
jwtRefreshExpiresIn: string;
|
||||||
@@ -34,6 +36,65 @@ const getOptionalEnv = (key: string, defaultValue: string): string => {
|
|||||||
return process.env[key] || defaultValue;
|
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 getOptionalBoolean = (key: string, defaultValue: boolean): boolean => {
|
||||||
const value = process.env[key];
|
const value = process.env[key];
|
||||||
if (!value) return defaultValue;
|
if (!value) return defaultValue;
|
||||||
@@ -53,9 +114,9 @@ const getRequiredEnvNumber = (key: string, defaultValue: number): number => {
|
|||||||
export const config: Config = {
|
export const config: Config = {
|
||||||
port: getRequiredEnvNumber("PORT", 8000),
|
port: getRequiredEnvNumber("PORT", 8000),
|
||||||
nodeEnv: getOptionalEnv("NODE_ENV", "development"),
|
nodeEnv: getOptionalEnv("NODE_ENV", "development"),
|
||||||
databaseUrl: getRequiredEnv("DATABASE_URL"),
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
frontendUrl: getOptionalEnv("FRONTEND_URL", "http://localhost:6767"),
|
frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL),
|
||||||
jwtSecret: getRequiredEnv("JWT_SECRET"),
|
jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")),
|
||||||
jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"),
|
jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"),
|
||||||
jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"),
|
jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"),
|
||||||
rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000),
|
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");
|
||||||
@@ -33,37 +33,6 @@ import { logAuditEvent } from "./utils/audit";
|
|||||||
|
|
||||||
const backendRoot = path.resolve(__dirname, "../");
|
const backendRoot = path.resolve(__dirname, "../");
|
||||||
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
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);
|
console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL);
|
||||||
|
|
||||||
// Helper to get the resolved database file path
|
// Helper to get the resolved database file path
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ declare global {
|
|||||||
interface Request {
|
interface Request {
|
||||||
user?: {
|
user?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
username?: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
role: string;
|
||||||
|
mustResetPassword?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +111,15 @@ export const requireAuth = async (
|
|||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.userId },
|
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) {
|
if (!user || !user.isActive) {
|
||||||
@@ -122,8 +133,11 @@ export const requireAuth = async (
|
|||||||
// Attach user to request
|
// Attach user to request
|
||||||
req.user = {
|
req.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
mustResetPassword: user.mustResetPassword,
|
||||||
};
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -160,14 +174,25 @@ export const optionalAuth = async (
|
|||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.userId },
|
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) {
|
if (user && user.isActive) {
|
||||||
req.user = {
|
req.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
mustResetPassword: user.mustResetPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
import { PrismaClient } from "../generated/client";
|
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 {
|
export interface AuditLogData {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -27,7 +32,7 @@ export const logAuditEvent = async (data: AuditLogData): Promise<void> => {
|
|||||||
return; // Feature disabled, silently skip
|
return; // Feature disabled, silently skip
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await getPrisma().auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: data.userId || null,
|
userId: data.userId || null,
|
||||||
action: data.action,
|
action: data.action,
|
||||||
@@ -62,7 +67,7 @@ export const getAuditLogs = async (
|
|||||||
return []; // Feature disabled, return empty array
|
return []; // Feature disabled, return empty array
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = await prisma.auditLog.findMany({
|
const logs = await getPrisma().auditLog.findMany({
|
||||||
where: userId ? { userId } : undefined,
|
where: userId ? { userId } : undefined,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
|
|||||||
@@ -69,13 +69,6 @@ export const clearCsrfToken = (): void => {
|
|||||||
// Add request interceptor to include JWT and CSRF tokens
|
// Add request interceptor to include JWT and CSRF tokens
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
async (config) => {
|
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.)
|
// Auth endpoints that don't require authentication (login, register, etc.)
|
||||||
const publicAuthEndpoints = [
|
const publicAuthEndpoints = [
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
@@ -85,9 +78,7 @@ api.interceptors.request.use(
|
|||||||
'/auth/password-reset-confirm',
|
'/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 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
|
// Add JWT token to all requests except public auth endpoints
|
||||||
if (!isPublicAuthEndpoint) {
|
if (!isPublicAuthEndpoint) {
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@@ -6,8 +7,11 @@ const API_URL = import.meta.env.VITE_API_URL || "/api";
|
|||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
username?: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
role?: "ADMIN" | "USER" | string;
|
||||||
|
mustResetPassword?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import type { Collection } from '../types';
|
import type { Collection } from '../types';
|
||||||
import { User, Lock, Save, X } from 'lucide-react';
|
import { User, Lock, Save, X, Shield } from 'lucide-react';
|
||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
|
||||||
|
|
||||||
export const Profile: React.FC = () => {
|
export const Profile: React.FC = () => {
|
||||||
const { user: authUser, logout } = useAuth();
|
const { user: authUser, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isAdmin = authUser?.role === 'ADMIN';
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
|
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
|
||||||
|
const [registrationLoading, setRegistrationLoading] = useState(false);
|
||||||
|
|
||||||
// User info state
|
// User info state
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -36,12 +38,47 @@ export const Profile: React.FC = () => {
|
|||||||
setName(authUser.name);
|
setName(authUser.name);
|
||||||
setEmail(authUser.email);
|
setEmail(authUser.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const statusResponse = await api.api.get<{ registrationEnabled: boolean }>('/auth/status');
|
||||||
|
setRegistrationEnabled(statusResponse.data.registrationEnabled);
|
||||||
|
} else {
|
||||||
|
setRegistrationEnabled(null);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch data:', err);
|
console.error('Failed to fetch data:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
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) => {
|
const handleSelectCollection = (id: string | null | undefined) => {
|
||||||
if (id === undefined) navigate('/');
|
if (id === undefined) navigate('/');
|
||||||
@@ -226,6 +263,42 @@ export const Profile: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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="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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const Settings: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
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 blob = new Blob([response.data], { type: 'application/json' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -123,7 +123,7 @@ export const Settings: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
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 blob = new Blob([response.data], { type: 'application/json' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -135,7 +135,7 @@ export const Settings: React.FC = () => {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export failed:', 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.');
|
alert('Database export is not available. Please use JSON export instead.');
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to export data. Please try again.');
|
alert('Failed to export data. Please try again.');
|
||||||
@@ -156,7 +156,7 @@ export const Settings: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
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 blob = new Blob([response.data], { type: 'application/zip' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
|
|||||||
Reference in New Issue
Block a user