diff --git a/backend/prisma/migrations/20260206173000_add_auth_enabled_system_config/migration.sql b/backend/prisma/migrations/20260206173000_add_auth_enabled_system_config/migration.sql new file mode 100644 index 0000000..677ca93 --- /dev/null +++ b/backend/prisma/migrations/20260206173000_add_auth_enabled_system_config/migration.sql @@ -0,0 +1,5 @@ +-- Add authEnabled flag to SystemConfig to support single-user mode by default. + +-- SQLite supports simple ADD COLUMN for non-null with default. +ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 247699a..40cf1e0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { model SystemConfig { id String @id @default("default") + authEnabled Boolean @default(false) registrationEnabled Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 5b4b534..1674f5f 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -45,10 +45,26 @@ const ensureSystemConfig = async () => { return prisma.systemConfig.upsert({ where: { id: DEFAULT_SYSTEM_CONFIG_ID }, update: {}, - create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: false }, + create: { + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: false, + registrationEnabled: false, + }, }); }; +const ensureAuthEnabled = async (res: Response): Promise => { + const systemConfig = await ensureSystemConfig(); + if (!systemConfig.authEnabled) { + res.status(404).json({ + error: "Not found", + message: "Authentication is disabled", + }); + return false; + } + return true; +}; + // Rate limiting for auth endpoints (stricter than general rate limiting) const authRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -89,6 +105,10 @@ const adminRoleUpdateSchema = z.object({ role: z.enum(["ADMIN", "USER"]), }); +const authEnabledToggleSchema = z.object({ + enabled: z.boolean(), +}); + const findUserByIdentifier = async (identifier: string) => { const trimmed = identifier.trim(); if (trimmed.length === 0) return null; @@ -150,6 +170,7 @@ const getRefreshTokenExpiresAt = (): Date => */ router.post("/register", authRateLimiter, async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; const parsed = registerSchema.safeParse(req.body); if (!parsed.success) { @@ -380,6 +401,7 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => */ router.post("/login", authRateLimiter, async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; const parsed = loginSchema.safeParse(req.body); if (!parsed.success) { @@ -507,6 +529,7 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => { */ router.post("/refresh", async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; const { refreshToken: oldRefreshToken } = req.body; if (!oldRefreshToken || typeof oldRefreshToken !== "string") { @@ -639,6 +662,7 @@ router.post("/refresh", async (req: Request, res: Response) => { */ router.get("/me", requireAuth, async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", @@ -684,16 +708,31 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => { router.get("/status", optionalAuth, async (req: Request, res: Response) => { try { const systemConfig = await ensureSystemConfig(); + if (!systemConfig.authEnabled) { + return res.json({ + enabled: false, + authenticated: false, + authEnabled: false, + registrationEnabled: false, + bootstrapRequired: false, + user: null, + }); + } + const bootstrapUser = await prisma.user.findUnique({ where: { id: BOOTSTRAP_USER_ID }, select: { id: true, isActive: true }, }); + const activeUsers = await prisma.user.count({ where: { isActive: true } }); + const bootstrapRequired = + Boolean(bootstrapUser && bootstrapUser.isActive === false) && activeUsers === 0; res.json({ enabled: true, + authEnabled: true, authenticated: Boolean(req.user), registrationEnabled: systemConfig.registrationEnabled, - bootstrapRequired: Boolean(bootstrapUser && bootstrapUser.isActive === false), + bootstrapRequired, user: req.user ? { id: req.user.id, @@ -714,12 +753,97 @@ router.get("/status", optionalAuth, async (req: Request, res: Response) => { } }); +/** + * POST /auth/auth-enabled + * Enable/disable authentication mode. + * + * - Enabling auth is allowed without login (single-user mode). + * - Disabling auth requires an authenticated ADMIN. + */ +router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => { + try { + const parsed = authEnabledToggleSchema.safeParse(req.body); + if (!parsed.success) { + return res + .status(400) + .json({ error: "Bad request", message: "Invalid toggle payload" }); + } + + const systemConfig = await ensureSystemConfig(); + const current = systemConfig.authEnabled; + const next = parsed.data.enabled; + + if (current && !next) { + if (!req.user) { + return res + .status(401) + .json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.role !== "ADMIN") { + return res + .status(403) + .json({ error: "Forbidden", message: "Admin access required" }); + } + } + + // Ensure the bootstrap user exists for the bootstrap registration flow. + if (!current && next) { + const bootstrap = await prisma.user.findUnique({ + where: { id: BOOTSTRAP_USER_ID }, + select: { id: true }, + }); + if (!bootstrap) { + await prisma.user.create({ + data: { + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + } + } + + const updated = await prisma.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: { authEnabled: next }, + create: { + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: next, + registrationEnabled: systemConfig.registrationEnabled, + }, + }); + + const bootstrapUser = await prisma.user.findUnique({ + where: { id: BOOTSTRAP_USER_ID }, + select: { id: true, isActive: true }, + }); + const activeUsers = await prisma.user.count({ where: { isActive: true } }); + const bootstrapRequired = + Boolean(updated.authEnabled && bootstrapUser && bootstrapUser.isActive === false) && + activeUsers === 0; + + res.json({ authEnabled: updated.authEnabled, bootstrapRequired }); + } catch (error) { + console.error("Auth enabled toggle error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update authentication mode", + }); + } +}); + /** * POST /auth/registration/toggle * Enable/disable registration (admin-only) */ router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); } @@ -754,6 +878,7 @@ router.post("/registration/toggle", requireAuth, async (req: Request, res: Respo */ router.post("/admins", requireAuth, async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); } @@ -805,6 +930,7 @@ const passwordResetRequestSchema = z.object({ }); router.post("/password-reset-request", authRateLimiter, async (req: Request, res: Response) => { + if (!(await ensureAuthEnabled(res))) return; // Check if password reset feature is enabled if (!config.enablePasswordReset) { return res.status(404).json({ @@ -901,6 +1027,7 @@ const passwordResetConfirmSchema = z.object({ }); router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res: Response) => { + if (!(await ensureAuthEnabled(res))) return; // Check if password reset feature is enabled if (!config.enablePasswordReset) { return res.status(404).json({ @@ -1010,6 +1137,7 @@ const updateProfileSchema = z.object({ router.put("/profile", requireAuth, async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", @@ -1074,6 +1202,7 @@ const changePasswordSchema = z.object({ router.post("/change-password", requireAuth, authRateLimiter, async (req: Request, res: Response) => { try { + if (!(await ensureAuthEnabled(res))) return; if (!req.user) { return res.status(401).json({ error: "Unauthorized", diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index bb8b0ea..721e510 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -7,6 +7,76 @@ import { config } from "../config"; import { PrismaClient } from "../generated/client"; const prisma = new PrismaClient(); +const DEFAULT_SYSTEM_CONFIG_ID = "default"; +const BOOTSTRAP_USER_ID = "bootstrap-admin"; + +type AuthEnabledCache = { + value: boolean; + fetchedAt: number; +}; + +let authEnabledCache: AuthEnabledCache | null = null; +const AUTH_ENABLED_TTL_MS = 0; + +const getAuthEnabled = async (): Promise => { + const now = Date.now(); + if (authEnabledCache && now - authEnabledCache.fetchedAt < AUTH_ENABLED_TTL_MS) { + return authEnabledCache.value; + } + + const systemConfig = await prisma.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: {}, + create: { + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: false, + registrationEnabled: false, + }, + select: { authEnabled: true }, + }); + + authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now }; + return systemConfig.authEnabled; +}; + +const getBootstrapActingUser = async () => { + const user = await prisma.user.findUnique({ + where: { id: BOOTSTRAP_USER_ID }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + + if (user) return user; + + return prisma.user.create({ + data: { + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); +}; // Extend Express Request type to include user declare global { @@ -87,6 +157,30 @@ export const requireAuth = async ( res: Response, next: NextFunction ): Promise => { + // Single-user mode: authentication disabled -> treat all requests as the bootstrap user. + try { + const authEnabled = await getAuthEnabled(); + if (!authEnabled) { + const user = await getBootstrapActingUser(); + req.user = { + id: user.id, + username: user.username, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + }; + return next(); + } + } catch (error) { + console.error("Error reading auth mode:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to read authentication mode", + }); + return; + } + const token = extractToken(req); if (!token) { @@ -159,6 +253,16 @@ export const optionalAuth = async ( res: Response, next: NextFunction ): Promise => { + try { + const authEnabled = await getAuthEnabled(); + if (!authEnabled) { + return next(); + } + } catch (error) { + console.error("Error reading auth mode:", error); + return next(); + } + const token = extractToken(req); if (!token) { diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 2a9bd86..06c8cc5 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -7,9 +7,9 @@ interface ProtectedRouteProps { } export const ProtectedRoute: React.FC = ({ children }) => { - const { isAuthenticated, loading } = useAuth(); + const { isAuthenticated, loading, authEnabled, bootstrapRequired } = useAuth(); - if (loading) { + if (loading || authEnabled === null) { return (
Loading...
@@ -17,9 +17,18 @@ export const ProtectedRoute: React.FC = ({ children }) => { ); } + // Single-user mode: auth disabled -> allow access. + if (!authEnabled) { + return <>{children}; + } + if (!isAuthenticated) { + // If auth is enabled but no admin exists yet, force bootstrap registration. + if (bootstrapRequired) { + return ; + } return ; } return <>{children}; -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7ab8e4c..be79bff 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -122,7 +122,7 @@ export const Sidebar: React.FC = ({ onDrop }) => { const navigate = useNavigate(); - const { logout, user } = useAuth(); + const { logout, user, authEnabled } = useAuth(); const [isCreating, setIsCreating] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); const [editingId, setEditingId] = useState(null); @@ -286,18 +286,20 @@ export const Sidebar: React.FC = ({ Trash - + {authEnabled && ( + + )} {/* User info and logout */} -
+ {authEnabled && ( +
{user && (
{user.name}
@@ -327,7 +330,8 @@ export const Sidebar: React.FC = ({ Logout -
+
+ )}
@@ -402,4 +406,3 @@ export const Sidebar: React.FC = ({ ); }; - diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 6a8f921..c32cb03 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -17,6 +17,8 @@ interface User { interface AuthContextType { user: User | null; loading: boolean; + authEnabled: boolean | null; + bootstrapRequired: boolean; login: (email: string, password: string) => Promise; register: (email: string, password: string, name: string) => Promise; logout: () => void; @@ -32,12 +34,36 @@ const USER_KEY = 'excalidash-user'; export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [authEnabled, setAuthEnabled] = useState(null); + const [bootstrapRequired, setBootstrapRequired] = useState(false); const navigate = useNavigate(); // Load user from localStorage on mount useEffect(() => { const loadUser = async () => { try { + // Determine auth mode first (single-user mode vs multi-user auth). + try { + const statusResponse = await axios.get(`${API_URL}/auth/status`); + const enabled = + typeof statusResponse.data?.authEnabled === "boolean" + ? statusResponse.data.authEnabled + : typeof statusResponse.data?.enabled === "boolean" + ? statusResponse.data.enabled + : true; + setAuthEnabled(enabled); + setBootstrapRequired(Boolean(statusResponse.data?.bootstrapRequired)); + + // In single-user mode, do not require login. + if (!enabled) { + setUser(null); + return; + } + } catch { + // If status fails, assume auth is enabled (safer default). + setAuthEnabled(true); + } + const storedUser = localStorage.getItem(USER_KEY); const storedToken = localStorage.getItem(TOKEN_KEY); @@ -101,6 +127,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const login = async (email: string, password: string) => { try { + if (authEnabled === false) { + throw new Error("Authentication is disabled"); + } const response = await axios.post(`${API_URL}/auth/login`, { email, password, @@ -130,6 +159,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const register = async (email: string, password: string, name: string) => { try { + if (authEnabled === false) { + throw new Error("Authentication is disabled"); + } const response = await axios.post(`${API_URL}/auth/register`, { email, password, @@ -174,6 +206,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => value={{ user, loading, + authEnabled, + bootstrapRequired, login, register, logout, diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index e0ba504..2b655e7 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { Logo } from '../components/Logo'; @@ -8,9 +8,24 @@ export const Login: React.FC = () => { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const { login } = useAuth(); + const { login, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading } = useAuth(); const navigate = useNavigate(); + useEffect(() => { + if (authLoading || authEnabled === null) return; + if (!authEnabled) { + navigate('/', { replace: true }); + return; + } + if (bootstrapRequired) { + navigate('/register', { replace: true }); + return; + } + if (isAuthenticated) { + navigate('/', { replace: true }); + } + }, [authEnabled, authLoading, bootstrapRequired, isAuthenticated, navigate]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -108,4 +123,4 @@ export const Login: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 8c645d5..d19f765 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -7,7 +7,7 @@ import type { Collection } from '../types'; import { User, Lock, Save, X, Shield } from 'lucide-react'; export const Profile: React.FC = () => { - const { user: authUser, logout } = useAuth(); + const { user: authUser, logout, authEnabled } = useAuth(); const navigate = useNavigate(); const isAdmin = authUser?.role === 'ADMIN'; const [collections, setCollections] = useState([]); @@ -28,6 +28,10 @@ export const Profile: React.FC = () => { const [showPasswordForm, setShowPasswordForm] = useState(false); useEffect(() => { + if (authEnabled === false) { + navigate('/settings', { replace: true }); + return; + } const fetchData = async () => { try { const collectionsData = await api.getCollections(); @@ -50,7 +54,7 @@ export const Profile: React.FC = () => { } }; fetchData(); - }, [authUser, isAdmin]); + }, [authEnabled, authUser, isAdmin, navigate]); const handleToggleRegistration = async () => { if (!isAdmin || registrationEnabled === null) return; diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 7ab61ef..0017d49 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { Logo } from '../components/Logo'; @@ -9,9 +9,20 @@ export const Register: React.FC = () => { const [name, setName] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const { register } = useAuth(); + const { register, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading } = useAuth(); const navigate = useNavigate(); + useEffect(() => { + if (authLoading || authEnabled === null) return; + if (!authEnabled) { + navigate('/', { replace: true }); + return; + } + if (isAuthenticated) { + navigate('/', { replace: true }); + } + }, [authEnabled, authLoading, isAuthenticated, navigate]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -40,16 +51,22 @@ export const Register: React.FC = () => {

- Create your account + {bootstrapRequired ? 'Set up admin account' : 'Create your account'}

- Or{' '} - - sign in to your existing account - + {bootstrapRequired ? ( + This will enable multi-user access for this ExcaliDash instance. + ) : ( + <> + Or{' '} + + sign in to your existing account + + + )}

@@ -123,4 +140,4 @@ export const Register: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1b33d8e..942b211 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -7,15 +7,19 @@ import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive } from 'lucide-r import { ConfirmModal } from '../components/ConfirmModal'; import { importDrawings } from '../utils/importUtils'; import { useTheme } from '../context/ThemeContext'; +import { useAuth } from '../context/AuthContext'; export const Settings: React.FC = () => { const [collections, setCollections] = useState([]); const navigate = useNavigate(); const { theme, toggleTheme } = useTheme(); + const { authEnabled, user } = useAuth(); const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null }); const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const [importSuccess, setImportSuccess] = useState(false); + const [authToggleLoading, setAuthToggleLoading] = useState(false); + const [authToggleError, setAuthToggleError] = useState(null); const appVersion = import.meta.env.VITE_APP_VERSION || 'Unknown version'; const buildLabel = import.meta.env.VITE_APP_BUILD_LABEL; @@ -32,6 +36,39 @@ export const Settings: React.FC = () => { fetchCollections(); }, []); + const toggleAuthEnabled = async () => { + if (authEnabled === null) return; + setAuthToggleLoading(true); + setAuthToggleError(null); + try { + const next = !authEnabled; + const response = await api.api.post<{ authEnabled: boolean; bootstrapRequired?: boolean }>( + '/auth/auth-enabled', + { enabled: next }, + ); + + if (response.data.authEnabled) { + // Auth enabled -> prompt admin bootstrap via register. + window.location.href = '/register'; + return; + } + + // Auth disabled -> reload to drop auth gating. + window.location.reload(); + } catch (err: unknown) { + let message = 'Failed to update authentication setting'; + if (api.isAxiosError(err)) { + message = + err.response?.data?.message || + err.response?.data?.error || + message; + } + setAuthToggleError(message); + } finally { + setAuthToggleLoading(false); + } + }; + const handleCreateCollection = async (name: string) => { await api.createCollection(name); const newCollections = await api.getCollections(); @@ -69,7 +106,37 @@ export const Settings: React.FC = () => { Settings + {authToggleError && ( +
+

{authToggleError}

+
+ )} +
+ +