feat(auth): default to single-user mode with enable toggle

This commit is contained in:
Zimeng Xiong
2026-02-06 09:45:38 -08:00
parent 40a645b823
commit 7977a3eb09
11 changed files with 425 additions and 37 deletions
+12 -3
View File
@@ -7,9 +7,9 @@ interface ProtectedRouteProps {
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
const { isAuthenticated, loading, authEnabled, bootstrapRequired } = useAuth();
if (loading) {
if (loading || authEnabled === null) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
@@ -17,9 +17,18 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ 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 <Navigate to="/register" replace />;
}
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
};
+19 -16
View File
@@ -122,7 +122,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
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<string | null>(null);
@@ -286,18 +286,20 @@ export const Sidebar: React.FC<SidebarProps> = ({
<span className="min-w-0 flex-1 text-left">Trash</span>
</button>
<button
onClick={() => navigate('/profile')}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
selectedCollectionId === 'PROFILE'
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<User size={18} />
<span className="min-w-0 flex-1 text-left">Profile</span>
</button>
{authEnabled && (
<button
onClick={() => navigate('/profile')}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
selectedCollectionId === 'PROFILE'
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<User size={18} />
<span className="min-w-0 flex-1 text-left">Profile</span>
</button>
)}
<button
onClick={() => navigate('/settings')}
@@ -313,7 +315,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
</button>
{/* User info and logout */}
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
{authEnabled && (
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
{user && (
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
@@ -327,7 +330,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
<LogOut size={18} />
<span className="min-w-0 flex-1 text-left">Logout</span>
</button>
</div>
</div>
)}
</div>
</div>
@@ -402,4 +406,3 @@ export const Sidebar: React.FC<SidebarProps> = ({
</>
);
};
+34
View File
@@ -17,6 +17,8 @@ interface User {
interface AuthContextType {
user: User | null;
loading: boolean;
authEnabled: boolean | null;
bootstrapRequired: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
@@ -32,12 +34,36 @@ const USER_KEY = 'excalidash-user';
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [authEnabled, setAuthEnabled] = useState<boolean | null>(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,
+18 -3
View File
@@ -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 = () => {
</div>
</div>
);
};
};
+6 -2
View File
@@ -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<Collection[]>([]);
@@ -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;
+28 -11
View File
@@ -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 = () => {
<div className="text-center">
<Logo className="mx-auto h-12 w-auto" />
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
Create your account
{bootstrapRequired ? 'Set up admin account' : 'Create your account'}
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
to="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
sign in to your existing account
</Link>
{bootstrapRequired ? (
<span>This will enable multi-user access for this ExcaliDash instance.</span>
) : (
<>
Or{' '}
<Link
to="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
sign in to your existing account
</Link>
</>
)}
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
@@ -123,4 +140,4 @@ export const Register: React.FC = () => {
</div>
</div>
);
};
};
+67
View File
@@ -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<Collection[]>([]);
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<string | null>(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
</h1>
{authToggleError && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-xl">
<p className="text-red-800 dark:text-red-200 font-medium">{authToggleError}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<button
onClick={toggleAuthEnabled}
disabled={authEnabled === null || authToggleLoading || (authEnabled === true && user?.role !== 'ADMIN')}
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] disabled:hover:translate-y-0"
>
<div className="w-16 h-16 bg-slate-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-slate-200 dark:border-neutral-700 group-hover:border-slate-300 dark:group-hover:border-neutral-600 transition-colors">
<Info size={32} className="text-slate-700 dark:text-neutral-300" />
</div>
<div className="text-center">
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">
{authEnabled ? 'Authentication: On' : 'Authentication: Off'}
</h3>
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">
{authEnabled
? user?.role === 'ADMIN'
? (authToggleLoading ? 'Disabling…' : 'Disable multi-user login')
: 'Only admins can disable'
: authToggleLoading
? 'Enabling…'
: 'Enable multi-user login'}
</p>
</div>
</button>
<button
onClick={toggleTheme}
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"