From 1c71a08bbe82143afd79f6a7e67bd20beadb6651 Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Tue, 10 Feb 2026 14:45:34 -0800 Subject: [PATCH] Plan OIDC integration and audit --- README.md | 25 +- backend/.env.example | 3 +- backend/package.json | 3 + .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/scripts/simulate-auth-onboarding.cjs | 330 ++++++++++++++++++ .../__tests__/auth-onboarding.integration.ts | 164 +++++++++ backend/src/auth/adminRoutes.ts | 94 ++++- backend/src/auth/authMode.ts | 1 + backend/src/auth/coreRoutes.ts | 195 +++++++++-- backend/src/auth/schemas.ts | 4 + backend/src/config.ts | 3 +- docker-compose.prod.yml | 3 +- docker-compose.yml | 3 +- frontend/src/App.tsx | 2 + frontend/src/api/index.ts | 22 +- .../src/components/ImpersonationBanner.tsx | 229 ++++++++++++ frontend/src/components/Layout.tsx | 3 + frontend/src/components/ProtectedRoute.tsx | 13 +- frontend/src/components/Sidebar.tsx | 40 --- frontend/src/context/AuthContext.tsx | 16 + frontend/src/pages/Admin.tsx | 53 +-- frontend/src/pages/AuthSetupChoice.tsx | 191 ++++++++++ frontend/src/pages/Login.tsx | 17 +- frontend/src/pages/Register.tsx | 15 +- frontend/src/pages/Settings.tsx | 41 ++- 26 files changed, 1338 insertions(+), 135 deletions(-) create mode 100644 backend/prisma/migrations/20260210153000_add_auth_onboarding_completed/migration.sql create mode 100755 backend/scripts/simulate-auth-onboarding.cjs create mode 100644 backend/src/__tests__/auth-onboarding.integration.ts create mode 100644 frontend/src/components/ImpersonationBanner.tsx create mode 100644 frontend/src/pages/AuthSetupChoice.tsx diff --git a/README.md b/README.md index 67183e1..e038d7b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ docker compose -f docker-compose.prod.yml up -d For single-container deployments, `JWT_SECRET` can be omitted and will be auto-generated and persisted in the backend volume on first start. For portability and all multi-instance deployments, set a fixed `JWT_SECRET` explicitly. +By default, the provided Compose files set `TRUST_PROXY=false` for safer setup. Only set `TRUST_PROXY` to a positive hop count (for example, `1`) when requests always pass through a trusted reverse proxy that correctly sets forwarded headers. + ## Docker Build [Install Docker](https://docs.docker.com/desktop/) @@ -123,7 +125,7 @@ docker compose up -d When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly: - `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses. -- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one reverse proxy hop (for example: frontend nginx -> backend). This ensures rate limiting and logging use the real client IP from trusted proxy headers. +- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one trusted reverse proxy hop (for example: frontend nginx -> backend) and forwarded headers are sanitized. This ensures rate limiting and logging use the real client IP from trusted proxy headers. - `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname. ```yaml @@ -203,6 +205,27 @@ npx prisma db push npm run dev ``` +### Simulate Auth Onboarding (Development) + +To simulate first-run authentication choice flows in local development: + +```bash +cd ExcaliDash/backend + +# Preview what would change (no data modifications) +npm run dev:simulate-auth-onboarding:dry-run + +# Simulate "fresh install" onboarding state +# (wipes drawings/collections/libraries and removes non-bootstrap users) +npm run dev:simulate-auth-onboarding:fresh + +# Simulate "migration" onboarding state (ensures legacy data exists) +npm run dev:simulate-auth-onboarding:migration +``` + +After running a simulation while the backend is already running, wait about 5 seconds +(auth mode cache TTL) or restart the backend before refreshing the UI. + ## Project Structure ``` diff --git a/backend/.env.example b/backend/.env.example index 9e4e08a..054e3cb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,7 +3,8 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db FRONTEND_URL=http://localhost:6767 -TRUST_PROXY=1 +# Keep disabled unless traffic always comes through a trusted reverse proxy. +TRUST_PROXY=false JWT_SECRET=change-this-secret-in-production-min-32-chars # Optional Feature Flags (all default to false for backward compatibility) diff --git a/backend/package.json b/backend/package.json index 972c4f2..af2e214 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,9 @@ "predev": "node scripts/predev-migrate.cjs", "dev": "nodemon src/index.ts", "admin:recover": "node scripts/admin-recover.cjs", + "dev:simulate-auth-onboarding:fresh": "node scripts/simulate-auth-onboarding.cjs --scenario fresh", + "dev:simulate-auth-onboarding:migration": "node scripts/simulate-auth-onboarding.cjs --scenario migration", + "dev:simulate-auth-onboarding:dry-run": "node scripts/simulate-auth-onboarding.cjs --scenario migration --dry-run", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" diff --git a/backend/prisma/migrations/20260210153000_add_auth_onboarding_completed/migration.sql b/backend/prisma/migrations/20260210153000_add_auth_onboarding_completed/migration.sql new file mode 100644 index 0000000..bb9dd19 --- /dev/null +++ b/backend/prisma/migrations/20260210153000_add_auth_onboarding_completed/migration.sql @@ -0,0 +1,2 @@ +-- Track whether initial auth mode choice has been explicitly completed. +ALTER TABLE "SystemConfig" ADD COLUMN "authOnboardingCompleted" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b719647..fd36384 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -33,6 +33,7 @@ model User { model SystemConfig { id String @id @default("default") authEnabled Boolean @default(false) + authOnboardingCompleted Boolean @default(false) registrationEnabled Boolean @default(false) authLoginRateLimitEnabled Boolean @default(true) authLoginRateLimitWindowMs Int @default(900000) // 15 minutes diff --git a/backend/scripts/simulate-auth-onboarding.cjs b/backend/scripts/simulate-auth-onboarding.cjs new file mode 100755 index 0000000..35d1207 --- /dev/null +++ b/backend/scripts/simulate-auth-onboarding.cjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node + +require("dotenv").config(); + +const path = require("path"); +const { execSync } = require("child_process"); +const { PrismaClient } = require("../src/generated/client"); + +const BOOTSTRAP_USER_ID = "bootstrap-admin"; +const DEFAULT_SYSTEM_CONFIG_ID = "default"; +const backendRoot = path.resolve(__dirname, ".."); + +const resolveDatabaseUrl = (rawUrl) => { + const backendRoot = path.resolve(__dirname, ".."); + const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); + + if (!rawUrl || String(rawUrl).trim().length === 0) { + return `file:${defaultDbPath}`; + } + + if (!String(rawUrl).startsWith("file:")) { + return String(rawUrl); + } + + const filePath = String(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}`; +}; + +process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); + +const parseArgs = (argv) => { + const parsed = { + scenario: "", + dryRun: false, + allowProd: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--scenario") { + parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase(); + i += 1; + continue; + } + if (token === "--dry-run") { + parsed.dryRun = true; + continue; + } + if (token === "--allow-production") { + parsed.allowProd = true; + continue; + } + if (token === "--help" || token === "-h") { + parsed.help = true; + continue; + } + } + + return parsed; +}; + +const usage = () => { + console.log(`Usage: + node scripts/simulate-auth-onboarding.cjs --scenario fresh + node scripts/simulate-auth-onboarding.cjs --scenario migration + +Options: + --dry-run Show what would change without modifying data + --allow-production Override production safety check (not recommended) + --help, -h Show this help +`); +}; + +const assertScenario = (scenario) => { + if (scenario !== "fresh" && scenario !== "migration") { + throw new Error("Invalid --scenario. Use 'fresh' or 'migration'."); + } +}; + +const nowIso = () => new Date().toISOString(); + +const run = async () => { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + usage(); + return; + } + + assertScenario(args.scenario); + +const nodeEnv = process.env.NODE_ENV || "development"; + if (nodeEnv === "production" && !args.allowProd) { + throw new Error( + "Refusing to run in production. Pass --allow-production only if you explicitly intend this." + ); + } + + // Keep migration history authoritative to avoid drift between db push and deploy. + // Includes a self-heal path for the known duplicate-column failure on + // 20260210153000_add_auth_onboarding_completed in local dev databases. + if (nodeEnv !== "production") { + const runDeploy = () => + execSync("npx prisma migrate deploy", { + cwd: backendRoot, + stdio: "pipe", + env: { + ...process.env, + DATABASE_URL: process.env.DATABASE_URL, + }, + }); + + try { + runDeploy(); + } catch (error) { + const stdout = + error && error.stdout + ? Buffer.isBuffer(error.stdout) + ? error.stdout.toString("utf8") + : String(error.stdout) + : ""; + const stderr = + error && error.stderr + ? Buffer.isBuffer(error.stderr) + ? error.stderr.toString("utf8") + : String(error.stderr) + : ""; + const combined = `${stdout}\n${stderr}`; + + const canAutoResolve = + combined.includes("Error: P3009") && + combined.includes("20260210153000_add_auth_onboarding_completed") && + combined.includes("duplicate column name: authOnboardingCompleted"); + + if (!canAutoResolve) { + throw error; + } + + execSync( + "npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed", + { + cwd: backendRoot, + stdio: "pipe", + env: { + ...process.env, + DATABASE_URL: process.env.DATABASE_URL, + }, + } + ); + runDeploy(); + } + } + + const prisma = new PrismaClient(); + + try { + const before = { + activeUsers: await prisma.user.count({ where: { isActive: true } }), + users: await prisma.user.count(), + drawings: await prisma.drawing.count(), + collections: await prisma.collection.count(), + auth: await prisma.systemConfig.findUnique({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + select: { + authEnabled: true, + authOnboardingCompleted: true, + registrationEnabled: true, + }, + }), + }; + + console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`); + console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`); + console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`); + console.log("[simulate-auth-onboarding] before:", before); + + if (args.dryRun) { + console.log("[simulate-auth-onboarding] dry-run only. No data changed."); + return; + } + + await prisma.$transaction(async (tx) => { + await tx.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: { + authEnabled: false, + authOnboardingCompleted: false, + registrationEnabled: false, + }, + create: { + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: false, + authOnboardingCompleted: false, + registrationEnabled: false, + authLoginRateLimitEnabled: true, + authLoginRateLimitWindowMs: 15 * 60 * 1000, + authLoginRateLimitMax: 20, + }, + }); + + await tx.user.updateMany({ + data: { + isActive: false, + mustResetPassword: true, + }, + }); + + await tx.user.upsert({ + where: { id: BOOTSTRAP_USER_ID }, + update: { + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + create: { + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + + if (args.scenario === "fresh") { + await tx.drawing.deleteMany({}); + await tx.collection.deleteMany({}); + await tx.library.deleteMany({}); + await tx.user.deleteMany({ + where: { + id: { + not: BOOTSTRAP_USER_ID, + }, + }, + }); + return; + } + + // Migration simulation: + // 1) Reassign existing data ownership to bootstrap user + // 2) Ensure at least one drawing+collection exists so UI shows migration messaging + await tx.collection.updateMany({ + data: { userId: BOOTSTRAP_USER_ID }, + }); + await tx.drawing.updateMany({ + data: { userId: BOOTSTRAP_USER_ID }, + }); + + const collectionCount = await tx.collection.count(); + let targetCollectionId = null; + + if (collectionCount === 0) { + targetCollectionId = `sim-migration-col-${Date.now()}`; + await tx.collection.create({ + data: { + id: targetCollectionId, + name: "Migrated Collection", + userId: BOOTSTRAP_USER_ID, + }, + }); + } else { + const existing = await tx.collection.findFirst({ + where: { userId: BOOTSTRAP_USER_ID }, + select: { id: true }, + orderBy: { createdAt: "asc" }, + }); + targetCollectionId = existing ? existing.id : null; + } + + const drawingCount = await tx.drawing.count(); + if (drawingCount === 0) { + await tx.drawing.create({ + data: { + id: `sim-migration-draw-${Date.now()}`, + name: "Migrated Drawing", + elements: "[]", + appState: "{}", + files: "{}", + preview: null, + version: 1, + userId: BOOTSTRAP_USER_ID, + collectionId: targetCollectionId, + }, + }); + } + }); + + const after = { + activeUsers: await prisma.user.count({ where: { isActive: true } }), + users: await prisma.user.count(), + drawings: await prisma.drawing.count(), + collections: await prisma.collection.count(), + auth: await prisma.systemConfig.findUnique({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + select: { + authEnabled: true, + authOnboardingCompleted: true, + registrationEnabled: true, + }, + }), + }; + + console.log("[simulate-auth-onboarding] after:", after); + console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`); + console.log( + "[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI." + ); + } finally { + await prisma.$disconnect().catch(() => {}); + } +}; + +run().catch((error) => { + console.error("simulate-auth-onboarding failed:", error.message || error); + process.exitCode = 1; +}); diff --git a/backend/src/__tests__/auth-onboarding.integration.ts b/backend/src/__tests__/auth-onboarding.integration.ts new file mode 100644 index 0000000..5407660 --- /dev/null +++ b/backend/src/__tests__/auth-onboarding.integration.ts @@ -0,0 +1,164 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import { PrismaClient } from "../generated/client"; +import { getTestPrisma, setupTestDb } from "./testUtils"; +import { BOOTSTRAP_USER_ID } from "../auth/authMode"; + +describe("Auth onboarding decision", () => { + const userAgent = "vitest-auth-onboarding"; + let prisma: PrismaClient; + let app: any; + let agent: any; + let csrfHeaderName: string; + let csrfToken: string; + + beforeAll(async () => { + setupTestDb(); + prisma = getTestPrisma(); + + ({ app } = await import("../index")); + + agent = request.agent(app); + const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent); + csrfHeaderName = csrfRes.body.header; + csrfToken = csrfRes.body.token; + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("reports migration onboarding mode when no active users and legacy data exists", async () => { + await prisma.user.upsert({ + where: { id: BOOTSTRAP_USER_ID }, + update: {}, + create: { + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + + await prisma.systemConfig.upsert({ + where: { id: "default" }, + update: { authEnabled: false, authOnboardingCompleted: false }, + create: { + id: "default", + authEnabled: false, + authOnboardingCompleted: false, + registrationEnabled: false, + }, + }); + + await prisma.collection.upsert({ + where: { id: "legacy-collection" }, + update: {}, + create: { + id: "legacy-collection", + name: "Legacy", + userId: BOOTSTRAP_USER_ID, + }, + }); + + await prisma.drawing.upsert({ + where: { id: "legacy-drawing" }, + update: {}, + create: { + id: "legacy-drawing", + name: "Legacy Drawing", + elements: "[]", + appState: "{}", + files: "{}", + userId: BOOTSTRAP_USER_ID, + collectionId: "legacy-collection", + }, + }); + + const response = await request(app).get("/auth/status").set("User-Agent", userAgent); + + expect(response.status).toBe(200); + expect(response.body?.authEnabled).toBe(false); + expect(response.body?.authOnboardingRequired).toBe(true); + expect(response.body?.authOnboardingMode).toBe("migration"); + }); + + it("persists a single-user onboarding choice", async () => { + await prisma.systemConfig.update({ + where: { id: "default" }, + data: { authEnabled: false, authOnboardingCompleted: false }, + }); + + const choiceResponse = await agent + .post("/auth/onboarding-choice") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ enableAuth: false }); + + expect(choiceResponse.status).toBe(200); + expect(choiceResponse.body?.authEnabled).toBe(false); + expect(choiceResponse.body?.authOnboardingCompleted).toBe(true); + + const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent); + expect(statusResponse.status).toBe(200); + expect(statusResponse.body?.authOnboardingRequired).toBe(false); + }); + + it("enables auth and bootstrap flow from onboarding choice", async () => { + await prisma.drawing.deleteMany({}); + await prisma.collection.deleteMany({ where: { id: { not: `trash:${BOOTSTRAP_USER_ID}` } } }); + await prisma.systemConfig.update({ + where: { id: "default" }, + data: { authEnabled: false, authOnboardingCompleted: false }, + }); + + const choiceResponse = await agent + .post("/auth/onboarding-choice") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ enableAuth: true }); + + expect(choiceResponse.status).toBe(200); + expect(choiceResponse.body?.authEnabled).toBe(true); + expect(choiceResponse.body?.bootstrapRequired).toBe(true); + expect(choiceResponse.body?.authOnboardingCompleted).toBe(true); + + const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent); + expect(statusResponse.status).toBe(200); + expect(statusResponse.body?.authEnabled).toBe(true); + expect(statusResponse.body?.bootstrapRequired).toBe(true); + expect(statusResponse.body?.authOnboardingRequired).toBe(false); + }); + + it("requires CSRF token for bootstrap registration", async () => { + const noCsrfResponse = await agent + .post("/auth/register") + .set("User-Agent", userAgent) + .send({ + email: "bootstrap-admin@test.local", + password: "StrongPass1!", + name: "Bootstrap Admin", + }); + + expect(noCsrfResponse.status).toBe(403); + expect(noCsrfResponse.body?.error).toBe("CSRF token missing"); + + const bootstrapResponse = await agent + .post("/auth/register") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ + email: "bootstrap-admin@test.local", + password: "StrongPass1!", + name: "Bootstrap Admin", + }); + + expect(bootstrapResponse.status).toBe(201); + expect(bootstrapResponse.body?.bootstrapped).toBe(true); + expect(bootstrapResponse.body?.user?.email).toBe("bootstrap-admin@test.local"); + }); +}); diff --git a/backend/src/auth/adminRoutes.ts b/backend/src/auth/adminRoutes.ts index 9cf0c3e..bed8691 100644 --- a/backend/src/auth/adminRoutes.ts +++ b/backend/src/auth/adminRoutes.ts @@ -96,6 +96,48 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { requireCsrf, } = deps; + const resolveImpersonationAdmin = async (req: Request, res: Response) => { + if (!req.user) { + res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + return null; + } + + if (req.user.role === "ADMIN") { + return { + id: req.user.id, + email: req.user.email, + name: req.user.name, + }; + } + + if (!req.user.impersonatorId) { + res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + return null; + } + + const impersonator = await prisma.user.findUnique({ + where: { id: req.user.impersonatorId }, + select: { + id: true, + email: true, + name: true, + role: true, + isActive: true, + }, + }); + + if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") { + res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + return null; + } + + return { + id: impersonator.id, + email: impersonator.email, + name: impersonator.name, + }; + }; + router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; @@ -210,6 +252,42 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { } }); + router.get("/impersonation-targets", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + const actingAdmin = await resolveImpersonationAdmin(req, res); + if (!actingAdmin) return; + + const users = await prisma.user.findMany({ + where: { isActive: true, id: { not: actingAdmin.id } }, + orderBy: [{ name: "asc" }, { email: "asc" }], + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + isActive: true, + }, + }); + + res.json({ + users, + impersonator: { + id: actingAdmin.id, + email: actingAdmin.email, + name: actingAdmin.name, + }, + }); + } catch (error) { + console.error("List impersonation targets error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to list impersonation targets", + }); + } + }); + router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; @@ -599,7 +677,8 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { try { if (!(await ensureAuthEnabled(res))) return; if (!requireCsrf(req, res)) return; - if (!requireAdmin(req, res)) return; + const actingAdmin = await resolveImpersonationAdmin(req, res); + if (!actingAdmin) return; const parsed = impersonateSchema.safeParse(req.body); if (!parsed.success) { @@ -615,12 +694,19 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { return res.status(404).json({ error: "Not found", message: "User not found" }); } + if (target.id === actingAdmin.id) { + return res.status(409).json({ + error: "Conflict", + message: "Already using the admin account. Use stop impersonation to return.", + }); + } + if (!target.isActive) { return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" }); } const { accessToken, refreshToken } = generateTokens(target.id, target.email, { - impersonatorId: req.user.id, + impersonatorId: actingAdmin.id, }); setAuthCookies(req, res, { accessToken, refreshToken }); @@ -639,12 +725,12 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { if (config.enableAuditLogging) { await logAuditEvent({ - userId: req.user.id, + userId: actingAdmin.id, action: "impersonation_started", resource: `user:${target.id}`, ipAddress: req.ip || req.connection.remoteAddress || undefined, userAgent: req.headers["user-agent"] || undefined, - details: { targetUserId: target.id }, + details: { targetUserId: target.id, initiatedFromImpersonation: Boolean(req.user?.impersonatorId) }, }); } diff --git a/backend/src/auth/authMode.ts b/backend/src/auth/authMode.ts index deca0d7..f2ecd43 100644 --- a/backend/src/auth/authMode.ts +++ b/backend/src/auth/authMode.ts @@ -31,6 +31,7 @@ export const createAuthModeService = ( create: { id: DEFAULT_SYSTEM_CONFIG_ID, authEnabled: false, + authOnboardingCompleted: false, registrationEnabled: false, authLoginRateLimitEnabled: true, authLoginRateLimitWindowMs: 15 * 60 * 1000, diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts index 89f828d..99eaa2a 100644 --- a/backend/src/auth/coreRoutes.ts +++ b/backend/src/auth/coreRoutes.ts @@ -1,10 +1,11 @@ import express, { Request, Response } from "express"; import bcrypt from "bcrypt"; import jwt, { SignOptions } from "jsonwebtoken"; -import { PrismaClient } from "../generated/client"; +import { Prisma, PrismaClient } from "../generated/client"; import { StringValue } from "ms"; import { logAuditEvent } from "../utils/audit"; import { + authOnboardingChoiceSchema, authEnabledToggleSchema, loginSchema, registerSchema, @@ -21,6 +22,7 @@ type RegisterCoreRoutesDeps = { ensureSystemConfig: () => Promise<{ id: string; authEnabled: boolean; + authOnboardingCompleted: boolean; registrationEnabled: boolean; }>; findUserByIdentifier: (identifier: string) => Promise<{ @@ -102,10 +104,54 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { readRefreshTokenFromRequest, } = deps; const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + const getAuthOnboardingStatus = async (systemConfig: { + authEnabled: boolean; + authOnboardingCompleted: boolean; + }) => { + const [activeUsers, drawingsCount, collectionsCount] = await Promise.all([ + prisma.user.count({ where: { isActive: true } }), + prisma.drawing.count(), + prisma.collection.count(), + ]); + const hasLegacyData = drawingsCount > 0 || collectionsCount > 0; + const needsChoice = + !systemConfig.authEnabled && + activeUsers === 0 && + !systemConfig.authOnboardingCompleted; + + return { + activeUsers, + hasLegacyData, + needsChoice, + mode: hasLegacyData ? "migration" : "fresh", + } as const; + }; + + const ensureBootstrapUserExists = async (): Promise => { + const bootstrap = await prisma.user.findUnique({ + where: { id: bootstrapUserId }, + select: { id: true }, + }); + if (bootstrap) return; + + await prisma.user.create({ + data: { + id: bootstrapUserId, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + }; router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; const parsed = registerSchema.safeParse(req.body); if (!parsed.success) { @@ -135,25 +181,66 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { const passwordHash = await bcrypt.hash(password, saltRounds); const sanitizedName = sanitizeText(name, 100); - const user = await prisma.user.update({ - where: { id: bootstrapUserId }, - 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, - }, + const existingEmailUser = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, }); + if (existingEmailUser && existingEmailUser.id !== bootstrapUserId) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + + if (username) { + const existingUsernameUser = await prisma.user.findFirst({ + where: { username }, + select: { id: true }, + }); + if (existingUsernameUser && existingUsernameUser.id !== bootstrapUserId) { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } + } + + let user: { + id: string; + email: string; + name: string; + role: string; + mustResetPassword: boolean; + }; + try { + user = await prisma.user.update({ + where: { id: bootstrapUserId }, + 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, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return res.status(409).json({ + error: "Conflict", + message: "User with this email or username already exists", + }); + } + throw error; + } const trashCollectionId = getUserTrashCollectionId(user.id); const existingTrash = await prisma.collection.findFirst({ @@ -747,6 +834,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { router.get("/status", optionalAuth, async (req: Request, res: Response) => { try { const systemConfig = await ensureSystemConfig(); + const onboarding = await getAuthOnboardingStatus(systemConfig); if (!systemConfig.authEnabled) { return res.json({ enabled: false, @@ -754,6 +842,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { authEnabled: false, registrationEnabled: false, bootstrapRequired: false, + authOnboardingRequired: onboarding.needsChoice, + authOnboardingMode: onboarding.mode, + authOnboardingRecommended: onboarding.needsChoice ? "enable" : null, user: null, }); } @@ -762,8 +853,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { where: { id: bootstrapUserId }, select: { id: true, isActive: true }, }); - const activeUsers = await prisma.user.count({ where: { isActive: true } }); - const bootstrapRequired = Boolean(bootstrapUser && bootstrapUser.isActive === false) && activeUsers === 0; + const bootstrapRequired = + Boolean(bootstrapUser && bootstrapUser.isActive === false) && + onboarding.activeUsers === 0; res.json({ enabled: true, @@ -771,6 +863,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { authenticated: Boolean(req.user), registrationEnabled: systemConfig.registrationEnabled, bootstrapRequired, + authOnboardingRequired: onboarding.needsChoice, + authOnboardingMode: onboarding.mode, + authOnboardingRecommended: onboarding.needsChoice ? "enable" : null, user: req.user ? { id: req.user.id, @@ -792,6 +887,61 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { } }); + router.post("/onboarding-choice", optionalAuth, async (req: Request, res: Response) => { + try { + if (!requireCsrf(req, res)) return; + const parsed = authOnboardingChoiceSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Bad request", + message: "Invalid onboarding choice payload", + }); + } + + const systemConfig = await ensureSystemConfig(); + const onboarding = await getAuthOnboardingStatus(systemConfig); + if (!onboarding.needsChoice) { + return res.status(409).json({ + error: "Conflict", + message: "Authentication onboarding is already completed", + }); + } + + const nextAuthEnabled = parsed.data.enableAuth; + if (nextAuthEnabled) { + await ensureBootstrapUserExists(); + } + + const updated = await prisma.systemConfig.upsert({ + where: { id: defaultSystemConfigId }, + update: { + authEnabled: nextAuthEnabled, + authOnboardingCompleted: true, + }, + create: { + id: defaultSystemConfigId, + authEnabled: nextAuthEnabled, + authOnboardingCompleted: true, + registrationEnabled: systemConfig.registrationEnabled, + }, + }); + + clearAuthEnabledCache(); + + return res.json({ + authEnabled: updated.authEnabled, + authOnboardingCompleted: updated.authOnboardingCompleted, + bootstrapRequired: Boolean(nextAuthEnabled), + }); + } catch (error) { + console.error("Auth onboarding choice error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to apply authentication onboarding choice", + }); + } + }); + router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => { try { if (!requireCsrf(req, res)) return; @@ -840,10 +990,11 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { const updated = await prisma.systemConfig.upsert({ where: { id: defaultSystemConfigId }, - update: { authEnabled: next }, + update: { authEnabled: next, authOnboardingCompleted: true }, create: { id: defaultSystemConfigId, authEnabled: next, + authOnboardingCompleted: true, registrationEnabled: systemConfig.registrationEnabled, }, }); diff --git a/backend/src/auth/schemas.ts b/backend/src/auth/schemas.ts index 8fa2ca6..86a6382 100644 --- a/backend/src/auth/schemas.ts +++ b/backend/src/auth/schemas.ts @@ -47,6 +47,10 @@ export const authEnabledToggleSchema = z.object({ enabled: z.boolean(), }); +export const authOnboardingChoiceSchema = z.object({ + enableAuth: z.boolean(), +}); + export const adminCreateUserSchema = z.object({ username: z.string().trim().min(3).max(50).optional(), email: z.string().email().toLowerCase().trim(), diff --git a/backend/src/config.ts b/backend/src/config.ts index 6f799fa..7aa0118 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -128,8 +128,7 @@ if (config.nodeEnv === "production") { throw new Error("JWT_SECRET must be at least 32 characters long in production"); } if ( - insecureJwtSecretPlaceholders.has(normalizedSecret) || - normalizedSecret.toLowerCase().includes("change-this-secret") + insecureJwtSecretPlaceholders.has(normalizedSecret) ) { throw new Error("JWT_SECRET must be changed from placeholder/default value in production"); } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 91d1c19..ec3ba12 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,7 +6,8 @@ services: - DATABASE_URL=file:/app/prisma/dev.db - PORT=8000 - NODE_ENV=production - - TRUST_PROXY=1 + # Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers. + - TRUST_PROXY=false # Optional for single-instance deployments: # if unset, backend auto-generates and persists one in the volume. # Recommended to set explicitly for portability and multi-instance setups. diff --git a/docker-compose.yml b/docker-compose.yml index baa76e7..48b458b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,8 @@ services: - DATABASE_URL=file:/app/prisma/dev.db - PORT=8000 - NODE_ENV=production - - TRUST_PROXY=1 + # Keep disabled by default; only enable when a trusted proxy sanitizes forwarded headers. + - TRUST_PROXY=false # Optional for single-instance deployments: # if unset, backend auto-generates and persists one in the volume. # Recommended to set explicitly for portability and multi-instance setups. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61552ed..9685e83 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login } const Register = lazy(() => import('./pages/Register').then(m => ({ default: m.Register }))); const PasswordResetRequest = lazy(() => import('./pages/PasswordResetRequest').then(m => ({ default: m.PasswordResetRequest }))); const PasswordResetConfirm = lazy(() => import('./pages/PasswordResetConfirm').then(m => ({ default: m.PasswordResetConfirm }))); +const AuthSetupChoice = lazy(() => import('./pages/AuthSetupChoice').then(m => ({ default: m.AuthSetupChoice }))); const PageLoader = () => (
@@ -34,6 +35,7 @@ function App() { } /> } /> } /> + } /> => { - const response = await axios.post<{ user: AuthUser; accessToken: string; refreshToken: string }>( - `${API_URL}/auth/register`, - { email, password, name }, - { withCredentials: true } + const response = await api.post<{ user: AuthUser; accessToken: string; refreshToken: string }>( + "/auth/register", + { email, password, name } ); return response.data; }; +export const authOnboardingChoice = async ( + enableAuth: boolean +): Promise<{ authEnabled: boolean; authOnboardingCompleted: boolean; bootstrapRequired: boolean }> => { + const response = await api.post<{ + authEnabled: boolean; + authOnboardingCompleted: boolean; + bootstrapRequired: boolean; + }>('/auth/onboarding-choice', { enableAuth }); + return response.data; +}; + export const authPasswordResetConfirm = async ( token: string, password: string @@ -225,7 +238,6 @@ api.interceptors.request.use( // Auth endpoints that don't require authentication (login, register, etc.) const publicAuthEndpoints = [ '/auth/login', - '/auth/register', '/auth/refresh', '/auth/password-reset-request', '/auth/password-reset-confirm', diff --git a/frontend/src/components/ImpersonationBanner.tsx b/frontend/src/components/ImpersonationBanner.tsx new file mode 100644 index 0000000..f685fae --- /dev/null +++ b/frontend/src/components/ImpersonationBanner.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { LogIn, RefreshCw, XCircle } from 'lucide-react'; +import { api, isAxiosError } from '../api'; +import { useAuth } from '../context/AuthContext'; +import { + IMPERSONATION_KEY, + USER_KEY, + readImpersonationState, + stopImpersonation as restoreImpersonation, + type ImpersonationState, +} from '../utils/impersonation'; + +type ImpersonationTarget = { + id: string; + email: string; + name: string; + role: string; + isActive: boolean; +}; + +type ImpersonationTargetsResponse = { + users: ImpersonationTarget[]; +}; + +type ImpersonateResponse = { + user: { + id: string; + email: string; + name: string; + }; +}; + +const normalizeTarget = (target: ImpersonationState['target']): ImpersonationTarget => ({ + id: target.id, + email: target.email, + name: target.name, + role: 'USER', + isActive: true, +}); + +export const ImpersonationBanner: React.FC = () => { + const { authEnabled } = useAuth(); + const [impersonation, setImpersonation] = useState(null); + const [targets, setTargets] = useState([]); + const [loadingTargets, setLoadingTargets] = useState(false); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!authEnabled) { + setImpersonation(null); + return; + } + + const sync = () => setImpersonation(readImpersonationState()); + sync(); + window.addEventListener('storage', sync); + return () => window.removeEventListener('storage', sync); + }, [authEnabled]); + + const loadTargets = async () => { + if (!authEnabled || !impersonation) return; + + setLoadingTargets(true); + setError(''); + + try { + const response = await api.get('/auth/impersonation-targets'); + setTargets(response.data.users || []); + } catch (err: unknown) { + let message = 'Failed to load impersonation targets'; + if (isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setError(message); + setTargets([]); + } finally { + setLoadingTargets(false); + } + }; + + useEffect(() => { + void loadTargets(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authEnabled, impersonation?.target.id, impersonation?.impersonator.id]); + + const options = useMemo(() => { + if (!impersonation) return []; + const currentTarget = normalizeTarget(impersonation.target); + const targetMap = new Map(); + targetMap.set(currentTarget.id, currentTarget); + for (const user of targets) { + if (!user?.id) continue; + targetMap.set(user.id, user); + } + return Array.from(targetMap.values()).sort((a, b) => { + const byName = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + if (byName !== 0) return byName; + return a.email.localeCompare(b.email, undefined, { sensitivity: 'base' }); + }); + }, [impersonation, targets]); + + const stop = async () => { + if (!impersonation || busy) return; + setBusy(true); + setError(''); + + try { + const response = await api.post<{ user?: { id: string; email: string; name: string } }>('/auth/stop-impersonation'); + restoreImpersonation(); + if (response.data?.user) { + localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); + } + window.location.reload(); + } catch (err: unknown) { + let message = 'Failed to stop impersonation'; + if (isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setError(message); + setBusy(false); + } + }; + + const switchTarget = async (userId: string) => { + if (!impersonation || busy || userId === impersonation.target.id) return; + + setBusy(true); + setError(''); + + try { + const response = await api.post('/auth/impersonate', { userId }); + const latest = readImpersonationState() || impersonation; + const nextState: ImpersonationState = { + ...latest, + target: { + id: response.data.user.id, + email: response.data.user.email, + name: response.data.user.name, + }, + startedAt: new Date().toISOString(), + }; + + localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(nextState)); + localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); + window.location.reload(); + } catch (err: unknown) { + let message = 'Failed to switch impersonation user'; + if (isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setError(message); + setBusy(false); + } + }; + + if (!authEnabled || !impersonation) { + return null; + } + + return ( +
+
+
+
+ + Impersonating: +
+
+ {impersonation.target.name} ({impersonation.target.email}) +
+
+ Acting as this account. Stop to return to {impersonation.impersonator.email}. +
+
+ +
+ + + +
+
+ + {(loadingTargets || error) && ( +
+ {loadingTargets ? ( + + + Loading users... + + ) : null} + {error ? {error} : null} + {error ? ( + + ) : null} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a590972..f396e9a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { Menu, X } from 'lucide-react'; import { Sidebar } from './Sidebar'; import { Logo } from './Logo'; import { UploadStatus } from './UploadStatus'; +import { ImpersonationBanner } from './ImpersonationBanner'; import type { Collection } from '../types'; import clsx from 'clsx'; @@ -129,6 +130,7 @@ export const Layout: React.FC = ({
+ {children}
@@ -197,6 +199,7 @@ export const Layout: React.FC = ({
+ {children}
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index dcc6a4f..07b66f2 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -8,7 +8,14 @@ interface ProtectedRouteProps { export const ProtectedRoute: React.FC = ({ children }) => { const location = useLocation(); - const { isAuthenticated, loading, authEnabled, bootstrapRequired, user } = useAuth(); + const { + isAuthenticated, + loading, + authEnabled, + bootstrapRequired, + authOnboardingRequired, + user, + } = useAuth(); if (loading || authEnabled === null) { return ( @@ -18,6 +25,10 @@ export const ProtectedRoute: React.FC = ({ children }) => { ); } + if (authOnboardingRequired && location.pathname !== '/auth-setup') { + return ; + } + // Single-user mode: auth disabled -> allow access. if (!authEnabled) { return <>{children}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index cd42f72..b98f9cb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,7 +6,6 @@ import clsx from 'clsx'; import { ConfirmModal } from './ConfirmModal'; import { Logo } from './Logo'; import { useAuth } from '../context/AuthContext'; -import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation'; import { getInitialsFromName } from '../utils/user'; interface SidebarProps { @@ -124,7 +123,6 @@ export const Sidebar: React.FC = ({ const navigate = useNavigate(); const { logout, user, authEnabled } = useAuth(); const isAdmin = user?.role === 'ADMIN'; - const [impersonation, setImpersonation] = useState(null); const [isCreating, setIsCreating] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); const [editingId, setEditingId] = useState(null); @@ -139,18 +137,6 @@ export const Sidebar: React.FC = ({ return () => document.removeEventListener('click', handleClickOutside); }, []); - useEffect(() => { - if (!authEnabled) { - setImpersonation(null); - return; - } - const sync = () => setImpersonation(readImpersonationState()); - sync(); - window.addEventListener('storage', sync); - return () => window.removeEventListener('storage', sync); - }, [authEnabled]); - - const handleCreateSubmit = (e: React.FormEvent) => { e.preventDefault(); if (newCollectionName.trim()) { @@ -345,32 +331,6 @@ export const Sidebar: React.FC = ({ {/* User info and logout */} {authEnabled && (
- {impersonation && ( -
-
-
-
- Impersonating -
-
- {user?.email} -
-
- Return to {impersonation.impersonator.email} -
-
- -
-
- )} {user && (
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index d75491a..b9efb26 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -25,6 +25,8 @@ interface AuthContextType { loading: boolean; authEnabled: boolean | null; bootstrapRequired: boolean; + authOnboardingRequired: boolean; + authOnboardingMode: 'migration' | 'fresh' | null; login: (email: string, password: string) => Promise; register: (email: string, password: string, name: string) => Promise; logout: () => void; @@ -41,6 +43,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [loading, setLoading] = useState(true); const [authEnabled, setAuthEnabled] = useState(null); const [bootstrapRequired, setBootstrapRequired] = useState(false); + const [authOnboardingRequired, setAuthOnboardingRequired] = useState(false); + const [authOnboardingMode, setAuthOnboardingMode] = useState<'migration' | 'fresh' | null>(null); const navigate = useNavigate(); useEffect(() => { @@ -57,6 +61,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setAuthEnabled(enabled); localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled)); setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired)); + setAuthOnboardingRequired(Boolean(statusResponse?.authOnboardingRequired)); + setAuthOnboardingMode( + statusResponse?.authOnboardingMode === 'migration' || statusResponse?.authOnboardingMode === 'fresh' + ? statusResponse.authOnboardingMode + : null + ); if (!enabled) { localStorage.removeItem(USER_KEY); @@ -68,12 +78,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => if (cachedAuthEnabled === "false") { setAuthEnabled(false); setBootstrapRequired(false); + setAuthOnboardingRequired(false); + setAuthOnboardingMode(null); localStorage.removeItem(USER_KEY); setUser(null); return; } setAuthEnabled(true); setBootstrapRequired(false); + setAuthOnboardingRequired(false); + setAuthOnboardingMode(null); } const storedUser = localStorage.getItem(USER_KEY); @@ -179,6 +193,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => loading, authEnabled, bootstrapRequired, + authOnboardingRequired, + authOnboardingMode, login, register, logout, diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index dfb7f4c..ea5e07c 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,16 +1,15 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { ConfirmModal } from '../components/ConfirmModal'; import { useAuth } from '../context/AuthContext'; import * as api from '../api'; import type { Collection } from '../types'; -import { Shield, UserPlus, RefreshCw, UserCog, LogIn, XCircle, Settings as SettingsIcon, KeyRound } from 'lucide-react'; +import { Shield, UserPlus, RefreshCw, UserCog, LogIn, Settings as SettingsIcon, KeyRound } from 'lucide-react'; import { IMPERSONATION_KEY, type ImpersonationState, readImpersonationState, - stopImpersonation as restoreImpersonation, USER_KEY, } from '../utils/impersonation'; @@ -58,11 +57,6 @@ export const Admin: React.FC = () => { const [resetIdentifier, setResetIdentifier] = useState(''); const [resetLoading, setResetLoading] = useState(false); - const impersonation = useMemo(() => { - if (!authEnabled) return null; - return readImpersonationState(); - }, [authEnabled]); - useEffect(() => { if (authEnabled === false) { navigate('/settings', { replace: true }); @@ -331,28 +325,6 @@ export const Admin: React.FC = () => { } }; - const stopImpersonation = async () => { - if (!readImpersonationState()) return; - - try { - const response = await api.api.post<{ - user?: { id: string; email: string; name: string }; - }>('/auth/stop-impersonation'); - - restoreImpersonation(); - if (response.data?.user) { - localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); - } - window.location.href = '/admin'; - } catch (err: unknown) { - let message = 'Failed to stop impersonation'; - if (api.isAxiosError(err)) { - message = err.response?.data?.message || err.response?.data?.error || message; - } - setError(message); - } - }; - if (authEnabled === null) { return (
@@ -399,27 +371,6 @@ export const Admin: React.FC = () => {
- {impersonation && ( -
-
-
- - Impersonating {impersonation.target.email} -
-
- Stop impersonation to return to {impersonation.impersonator.email}. -
-
- -
- )} - {success && (

{success}

diff --git a/frontend/src/pages/AuthSetupChoice.tsx b/frontend/src/pages/AuthSetupChoice.tsx new file mode 100644 index 0000000..222300d --- /dev/null +++ b/frontend/src/pages/AuthSetupChoice.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { AlertTriangle, Shield, ShieldOff } from 'lucide-react'; +import { Logo } from '../components/Logo'; +import { useAuth } from '../context/AuthContext'; +import * as api from '../api'; + +type Step = 'choice' | 'confirm-disable'; + +export const AuthSetupChoice: React.FC = () => { + const navigate = useNavigate(); + const { + loading: authLoading, + authEnabled, + bootstrapRequired, + isAuthenticated, + authOnboardingRequired, + authOnboardingMode, + } = useAuth(); + + const [step, setStep] = useState('choice'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (authLoading || authEnabled === null) return; + if (authOnboardingRequired) return; + + if (!authEnabled) { + navigate('/', { replace: true }); + return; + } + + if (bootstrapRequired) { + navigate('/register', { replace: true }); + return; + } + + if (isAuthenticated) { + navigate('/', { replace: true }); + return; + } + + navigate('/login', { replace: true }); + }, [ + authEnabled, + authLoading, + authOnboardingRequired, + bootstrapRequired, + isAuthenticated, + navigate, + ]); + + const isMigrationMode = authOnboardingMode === 'migration'; + + const applyChoice = async (enableAuth: boolean) => { + setSubmitting(true); + setError(''); + try { + const response = await api.authOnboardingChoice(enableAuth); + localStorage.setItem('excalidash-auth-enabled', String(response.authEnabled)); + + if (response.authEnabled) { + window.location.href = response.bootstrapRequired ? '/register' : '/login'; + return; + } + + window.location.href = '/'; + } catch (err: unknown) { + let message = 'Failed to apply authentication choice'; + if (api.isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setError(message); + setSubmitting(false); + } + }; + + if (authLoading || authEnabled === null || !authOnboardingRequired) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+
+ +

+ {step === 'choice' ? 'Choose Authentication Mode' : 'Keep Authentication Disabled?'} +

+

+ {step === 'choice' + ? isMigrationMode + ? 'We detected existing data from an earlier ExcaliDash version.' + : 'This looks like a new ExcaliDash setup.' + : 'This option is only recommended for private, trusted networks.'} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {step === 'choice' ? ( + <> +
+
Enable authentication now?
+
If enabled, users must sign in and you will set up the first admin account.
+
+ +
+ Recommendation: choose Enable Authentication. +
+ + {isMigrationMode && ( +
+ ExcaliDash v0.4 adds multi-user and OIDC support. Enabling authentication secures upgraded instances before sharing access. +
+ )} + +
+ + + +
+ + ) : ( + <> +
+
+ +
+ With authentication disabled, anyone who can access this instance can use all data and settings. + They can also enable authentication themselves and lock you out. +
+
+
+ +
+ + + +
+ + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d94fc80..dd0fb8c 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -12,7 +12,16 @@ export const Login: React.FC = () => { const [confirmNewPassword, setConfirmNewPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const { login, logout, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading, user } = useAuth(); + const { + login, + logout, + authEnabled, + bootstrapRequired, + authOnboardingRequired, + isAuthenticated, + loading: authLoading, + user, + } = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const queryMustReset = searchParams.get('mustReset') === '1'; @@ -20,6 +29,10 @@ export const Login: React.FC = () => { useEffect(() => { if (authLoading || authEnabled === null) return; + if (authOnboardingRequired) { + navigate('/auth-setup', { replace: true }); + return; + } if (!authEnabled) { navigate('/', { replace: true }); return; @@ -32,7 +45,7 @@ export const Login: React.FC = () => { if (mustReset) return; navigate('/', { replace: true }); } - }, [authEnabled, authLoading, bootstrapRequired, isAuthenticated, mustReset, navigate]); + }, [authEnabled, authLoading, authOnboardingRequired, bootstrapRequired, isAuthenticated, mustReset, navigate]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 0017d49..7af07a6 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -9,11 +9,22 @@ export const Register: React.FC = () => { const [name, setName] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const { register, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading } = useAuth(); + const { + register, + authEnabled, + bootstrapRequired, + authOnboardingRequired, + isAuthenticated, + loading: authLoading, + } = useAuth(); const navigate = useNavigate(); useEffect(() => { if (authLoading || authEnabled === null) return; + if (authOnboardingRequired) { + navigate('/auth-setup', { replace: true }); + return; + } if (!authEnabled) { navigate('/', { replace: true }); return; @@ -21,7 +32,7 @@ export const Register: React.FC = () => { if (isAuthenticated) { navigate('/', { replace: true }); } - }, [authEnabled, authLoading, isAuthenticated, navigate]); + }, [authEnabled, authLoading, authOnboardingRequired, isAuthenticated, navigate]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 6eababb..a56e77a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -34,6 +34,7 @@ export const Settings: React.FC = () => { isOpen: false, nextEnabled: null, }); + const [authDisableFinalConfirmOpen, setAuthDisableFinalConfirmOpen] = useState(false); const [backupExportExt, setBackupExportExt] = useState<'excalidash' | 'excalidash.zip'>('excalidash'); const [backupImportConfirmation, setBackupImportConfirmation] = useState<{ @@ -512,20 +513,56 @@ export const Settings: React.FC = () => { message={ authToggleConfirm.nextEnabled ? 'This will require users to sign in. You will be prompted to set up an admin account immediately.' - : 'This will turn off multi-user authentication. Anyone with access to this instance can use the dashboard.' + : ( +
+
+ This will turn off authentication for the entire instance. +
+
+ Recommendation: keep authentication enabled unless this instance is fully private. +
+
+ ) } - confirmText={authToggleConfirm.nextEnabled ? 'Enable' : 'Disable'} + confirmText={authToggleConfirm.nextEnabled ? 'Enable' : 'Continue'} cancelText="Cancel" isDangerous={!authToggleConfirm.nextEnabled} onConfirm={async () => { const nextEnabled = authToggleConfirm.nextEnabled; setAuthToggleConfirm({ isOpen: false, nextEnabled: null }); if (typeof nextEnabled !== 'boolean') return; + if (!nextEnabled) { + setAuthDisableFinalConfirmOpen(true); + return; + } await setAuthEnabled(nextEnabled); }} onCancel={() => setAuthToggleConfirm({ isOpen: false, nextEnabled: null })} /> + +
+ With authentication off, any user who can access this URL can view and modify all drawings and settings. They can also turn authentication back on and lock you out. +
+
+ This is only safe on a trusted private network. +
+
+ } + confirmText="Disable Authentication" + cancelText="Keep Enabled (Recommended)" + isDangerous + onConfirm={async () => { + setAuthDisableFinalConfirmOpen(false); + await setAuthEnabled(false); + }} + onCancel={() => setAuthDisableFinalConfirmOpen(false)} + /> +