Plan OIDC integration and audit
This commit is contained in:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Executable
+330
@@ -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;
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> => {
|
||||
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,7 +181,39 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
const sanitizedName = sanitizeText(name, 100);
|
||||
|
||||
const user = await prisma.user.update({
|
||||
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,
|
||||
@@ -154,6 +232,15 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
@@ -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 = () => (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-neutral-950 flex items-center justify-center">
|
||||
@@ -34,6 +35,7 @@ function App() {
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/reset-password" element={<PasswordResetRequest />} />
|
||||
<Route path="/reset-password-confirm" element={<PasswordResetConfirm />} />
|
||||
<Route path="/auth-setup" element={<AuthSetupChoice />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -70,6 +70,9 @@ export interface AuthStatusResponse {
|
||||
authEnabled?: boolean;
|
||||
enabled?: boolean;
|
||||
bootstrapRequired?: boolean;
|
||||
authOnboardingRequired?: boolean;
|
||||
authOnboardingMode?: "migration" | "fresh";
|
||||
authOnboardingRecommended?: "enable" | null;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
@@ -132,14 +135,24 @@ export const authRegister = async (
|
||||
password: string,
|
||||
name: string
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> => {
|
||||
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',
|
||||
|
||||
@@ -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<ImpersonationState | null>(null);
|
||||
const [targets, setTargets] = useState<ImpersonationTarget[]>([]);
|
||||
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<ImpersonationTargetsResponse>('/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<string, ImpersonationTarget>();
|
||||
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<ImpersonateResponse>('/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 (
|
||||
<div className="mb-4 rounded-2xl border-2 border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 sm:p-4 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.18)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.12)]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-amber-900 dark:text-amber-200">
|
||||
<LogIn size={16} />
|
||||
<span className="text-sm font-bold uppercase tracking-wide">Impersonating:</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-amber-900 dark:text-amber-200 truncate">
|
||||
{impersonation.target.name} ({impersonation.target.email})
|
||||
</div>
|
||||
<div className="text-xs text-amber-800/90 dark:text-amber-200/80 truncate">
|
||||
Acting as this account. Stop to return to {impersonation.impersonator.email}.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 lg:flex-shrink-0 lg:justify-end">
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-amber-900 dark:text-amber-200">
|
||||
Switch user:
|
||||
</label>
|
||||
<select
|
||||
value={impersonation.target.id}
|
||||
onChange={(e) => {
|
||||
void switchTarget(e.target.value);
|
||||
}}
|
||||
disabled={busy || loadingTargets || options.length === 0}
|
||||
className="min-w-[220px] max-w-[320px] px-3 py-2 rounded-xl border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-sm font-semibold text-slate-900 dark:text-neutral-100 outline-none disabled:opacity-70"
|
||||
>
|
||||
{options.map((target) => (
|
||||
<option key={target.id} value={target.id}>
|
||||
{target.name} ({target.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center gap-2 px-3 py-2 rounded-xl border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-sm font-bold text-amber-900 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-all disabled:opacity-70"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(loadingTargets || error) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium text-amber-900 dark:text-amber-200">
|
||||
{loadingTargets ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
Loading users...
|
||||
</span>
|
||||
) : null}
|
||||
{error ? <span>{error}</span> : null}
|
||||
{error ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadTargets()}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 dark:border-amber-700 px-2 py-1"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<LayoutProps> = ({
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="max-w-[1600px] w-full mx-auto p-4 sm:p-6 lg:p-8 min-h-full">
|
||||
<ImpersonationBanner />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,6 +199,7 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
|
||||
<div className="max-w-[1600px] w-full mx-auto p-4 sm:p-6 lg:p-8 min-h-full">
|
||||
<ImpersonationBanner />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -8,7 +8,14 @@ interface ProtectedRouteProps {
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ 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<ProtectedRouteProps> = ({ children }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (authOnboardingRequired && location.pathname !== '/auth-setup') {
|
||||
return <Navigate to="/auth-setup" replace />;
|
||||
}
|
||||
|
||||
// Single-user mode: auth disabled -> allow access.
|
||||
if (!authEnabled) {
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -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<SidebarProps> = ({
|
||||
const navigate = useNavigate();
|
||||
const { logout, user, authEnabled } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const [impersonation, setImpersonation] = useState<ImpersonationState | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -139,18 +137,6 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
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<SidebarProps> = ({
|
||||
{/* User info and logout */}
|
||||
{authEnabled && (
|
||||
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
|
||||
{impersonation && (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-xl flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-amber-900 dark:text-amber-200 uppercase tracking-wide">
|
||||
Impersonating
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-amber-900 dark:text-amber-200 truncate">
|
||||
{user?.email}
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-800/80 dark:text-amber-200/70 truncate">
|
||||
Return to {impersonation.impersonator.email}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!restoreImpersonation()) return;
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-2.5 py-1.5 text-[11px] font-bold rounded-lg border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-all flex-shrink-0"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<div className="py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
||||
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-3">
|
||||
|
||||
@@ -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<void>;
|
||||
register: (email: string, password: string, name: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -41,6 +43,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authEnabled, setAuthEnabled] = useState<boolean | null>(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,
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -399,27 +371,6 @@ export const Admin: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{impersonation && (
|
||||
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-xl flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-bold text-amber-900 dark:text-amber-200 flex items-center gap-2">
|
||||
<LogIn size={16} />
|
||||
Impersonating {impersonation.target.email}
|
||||
</div>
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200/80 font-medium mt-1">
|
||||
Stop impersonation to return to {impersonation.impersonator.email}.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={stopImpersonation}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-bold rounded-xl border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-all"
|
||||
>
|
||||
<XCircle size={16} />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-xl">
|
||||
<p className="text-green-800 dark:text-green-200 font-medium">{success}</p>
|
||||
|
||||
@@ -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<Step>('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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 px-4 py-6 flex items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h1 className="mt-6 text-2xl sm:text-3xl font-extrabold text-gray-900 dark:text-white leading-tight">
|
||||
{step === 'choice' ? 'Choose Authentication Mode' : 'Keep Authentication Disabled?'}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm sm:text-base text-gray-600 dark:text-gray-300">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-900 p-6 sm:p-8 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.15)]">
|
||||
{error && (
|
||||
<div className="mb-5 rounded-lg border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'choice' ? (
|
||||
<>
|
||||
<div className="mb-6 rounded-lg border border-slate-200 dark:border-neutral-700 bg-slate-50 dark:bg-neutral-800 p-4 text-sm text-slate-700 dark:text-neutral-200">
|
||||
<div className="font-semibold mb-1">Enable authentication now?</div>
|
||||
<div>If enabled, users must sign in and you will set up the first admin account.</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-900/20 p-4 text-sm text-emerald-800 dark:text-emerald-200">
|
||||
Recommendation: choose <strong>Enable Authentication</strong>.
|
||||
</div>
|
||||
|
||||
{isMigrationMode && (
|
||||
<div className="mb-6 rounded-lg border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/20 p-4 text-sm text-blue-800 dark:text-blue-200">
|
||||
ExcaliDash v0.4 adds multi-user and OIDC support. Enabling authentication secures upgraded instances before sharing access.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
void applyChoice(true);
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 rounded-xl border-2 border-black bg-emerald-600 px-4 py-3 text-sm font-bold text-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 transition-all disabled:opacity-60"
|
||||
>
|
||||
<Shield size={18} />
|
||||
Enable Authentication
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => setStep('confirm-disable')}
|
||||
className="flex items-center justify-center gap-2 rounded-xl border-2 border-black bg-white dark:bg-neutral-800 px-4 py-3 text-sm font-bold text-gray-900 dark:text-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 transition-all disabled:opacity-60"
|
||||
>
|
||||
<ShieldOff size={18} />
|
||||
Keep Disabled
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 rounded-lg border border-rose-200 dark:border-rose-900 bg-rose-50 dark:bg-rose-900/20 p-4 text-sm text-rose-800 dark:text-rose-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => setStep('choice')}
|
||||
className="rounded-xl border-2 border-black bg-white dark:bg-neutral-800 px-4 py-3 text-sm font-bold text-gray-900 dark:text-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 transition-all disabled:opacity-60"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
void applyChoice(false);
|
||||
}}
|
||||
className="rounded-xl border-2 border-black bg-rose-600 px-4 py-3 text-sm font-bold text-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 transition-all disabled:opacity-60"
|
||||
>
|
||||
Confirm Disable Authentication
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user