Plan OIDC integration and audit
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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?"
|
||||
|
||||
Reference in New Issue
Block a user