Plan OIDC integration and audit

This commit is contained in:
Zimeng Xiong
2026-02-10 14:45:34 -08:00
parent bb028ef2db
commit 1c71a08bbe
26 changed files with 1338 additions and 135 deletions
+2
View File
@@ -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={
+17 -5
View File
@@ -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>
);
};
+3
View File
@@ -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>
+12 -1
View File
@@ -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}</>;
-40
View File
@@ -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">
+16
View File
@@ -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,
+2 -51
View File
@@ -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>
+191
View File
@@ -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>
);
};
+15 -2
View File
@@ -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();
+13 -2
View File
@@ -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();
+39 -2
View File
@@ -34,6 +34,7 @@ export const Settings: React.FC = () => {
isOpen: false,
nextEnabled: null,
});
const [authDisableFinalConfirmOpen, setAuthDisableFinalConfirmOpen] = useState(false);
const [backupExportExt, setBackupExportExt] = useState<'excalidash' | 'excalidash.zip'>('excalidash');
const [backupImportConfirmation, setBackupImportConfirmation] = useState<{
@@ -512,20 +513,56 @@ export const Settings: React.FC = () => {
message={
authToggleConfirm.nextEnabled
? 'This will require users to sign in. You will be prompted to set up an admin account immediately.'
: 'This will turn off multi-user authentication. Anyone with access to this instance can use the dashboard.'
: (
<div className="space-y-2 text-left">
<div>
This will turn off authentication for the entire instance.
</div>
<div className="font-semibold text-rose-700 dark:text-rose-300">
Recommendation: keep authentication enabled unless this instance is fully private.
</div>
</div>
)
}
confirmText={authToggleConfirm.nextEnabled ? 'Enable' : 'Disable'}
confirmText={authToggleConfirm.nextEnabled ? 'Enable' : 'Continue'}
cancelText="Cancel"
isDangerous={!authToggleConfirm.nextEnabled}
onConfirm={async () => {
const nextEnabled = authToggleConfirm.nextEnabled;
setAuthToggleConfirm({ isOpen: false, nextEnabled: null });
if (typeof nextEnabled !== 'boolean') return;
if (!nextEnabled) {
setAuthDisableFinalConfirmOpen(true);
return;
}
await setAuthEnabled(nextEnabled);
}}
onCancel={() => setAuthToggleConfirm({ isOpen: false, nextEnabled: null })}
/>
<ConfirmModal
isOpen={authDisableFinalConfirmOpen}
title="Final warning: disable authentication?"
message={
<div className="space-y-2 text-left">
<div>
With authentication off, any user who can access this URL can view and modify all drawings and settings. They can also turn authentication back on and lock you out.
</div>
<div className="font-semibold text-rose-700 dark:text-rose-300">
This is only safe on a trusted private network.
</div>
</div>
}
confirmText="Disable Authentication"
cancelText="Keep Enabled (Recommended)"
isDangerous
onConfirm={async () => {
setAuthDisableFinalConfirmOpen(false);
await setAuthEnabled(false);
}}
onCancel={() => setAuthDisableFinalConfirmOpen(false)}
/>
<ConfirmModal
isOpen={backupImportConfirmation.isOpen}
title="Import backup?"