fix impersonation issues

This commit is contained in:
Zimeng Xiong
2026-02-10 22:44:49 -08:00
parent 1c71a08bbe
commit 2cbd11cf0d
19 changed files with 1083 additions and 58 deletions
+21
View File
@@ -69,6 +69,10 @@ export const clearCsrfToken = (): void => {
export interface AuthStatusResponse {
authEnabled?: boolean;
enabled?: boolean;
authMode?: "local" | "hybrid" | "oidc_enforced";
oidcEnabled?: boolean;
oidcEnforced?: boolean;
oidcProvider?: string;
bootstrapRequired?: boolean;
authOnboardingRequired?: boolean;
authOnboardingMode?: "migration" | "fresh";
@@ -92,6 +96,13 @@ export const authStatus = async (): Promise<AuthStatusResponse> => {
return response.data;
};
export const startOidcSignIn = (returnTo?: string): void => {
const fallbackPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const requestedPath = typeof returnTo === "string" && returnTo.startsWith("/") ? returnTo : fallbackPath;
const safeReturnTo = requestedPath.startsWith("/") ? requestedPath : "/";
window.location.href = `/api/auth/oidc/start?returnTo=${encodeURIComponent(safeReturnTo)}`;
};
export const authMe = async (): Promise<{ user: AuthUser }> => {
const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, {
withCredentials: true,
@@ -204,6 +215,16 @@ const getAuthEnabledStatus = async (): Promise<boolean | null> => {
};
const redirectToLogin = async () => {
try {
const status = await authStatus();
if (status?.oidcEnforced) {
startOidcSignIn();
return;
}
} catch {
// Best-effort status probe; fall through to legacy behavior.
}
const authEnabled = await getAuthEnabledStatus();
if (authEnabled === false) return;
if (window.location.pathname !== '/login') {
+62 -26
View File
@@ -22,6 +22,13 @@ type ImpersonationTargetsResponse = {
users: ImpersonationTarget[];
};
type AuthStatusResponse = {
authenticated?: boolean;
user?: {
impersonatorId?: string;
} | null;
};
type ImpersonateResponse = {
user: {
id: string;
@@ -46,6 +53,11 @@ export const ImpersonationBanner: React.FC = () => {
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const clearLocalImpersonation = () => {
localStorage.removeItem(IMPERSONATION_KEY);
setImpersonation(null);
};
useEffect(() => {
if (!authEnabled) {
setImpersonation(null);
@@ -54,6 +66,20 @@ export const ImpersonationBanner: React.FC = () => {
const sync = () => setImpersonation(readImpersonationState());
sync();
const verifyServerImpersonationState = async () => {
try {
const response = await api.get<AuthStatusResponse>('/auth/status');
const serverImpersonating = Boolean(response.data?.authenticated && response.data?.user?.impersonatorId);
if (!serverImpersonating && readImpersonationState()) {
clearLocalImpersonation();
}
} catch {
// Ignore probe failures; retry on next render/event.
}
};
void verifyServerImpersonationState();
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
}, [authEnabled]);
@@ -116,6 +142,14 @@ export const ImpersonationBanner: React.FC = () => {
let message = 'Failed to stop impersonation';
if (isAxiosError(err)) {
message = err.response?.data?.message || err.response?.data?.error || message;
if (
err.response?.status === 409 &&
/not currently impersonating/i.test(message)
) {
clearLocalImpersonation();
window.location.reload();
return;
}
}
setError(message);
setBusy(false);
@@ -159,36 +193,38 @@ export const ImpersonationBanner: React.FC = () => {
}
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 className="sticky top-0 z-[45] -mt-2 mb-6 rounded-xl border border-red-200 dark:border-red-800/50 bg-red-50/80 dark:bg-red-950/30 backdrop-blur-md px-3 py-2 shadow-sm transition-all duration-200">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-red-700 dark:text-red-400 flex-shrink-0">
<LogIn size={14} strokeWidth={2.5} />
<span className="text-[10px] font-black uppercase tracking-wider">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 className="flex items-center gap-2 min-w-0">
<span className="text-sm font-bold text-red-900 dark:text-red-100 truncate">
{impersonation.target.name}
</span>
<span className="hidden sm:inline text-xs font-medium text-red-800/60 dark:text-red-200/40 truncate">
{impersonation.target.email}
</span>
</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>
<div className="flex items-center gap-2 ml-auto">
<div className="hidden lg:flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider text-red-700/60 dark:text-red-400/40">
Switch:
</div>
<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"
className="h-8 min-w-[140px] max-w-[200px] px-2 rounded-lg border border-red-200 dark:border-red-800/50 bg-white/50 dark:bg-neutral-900/50 text-xs font-bold text-red-900 dark:text-red-100 outline-none hover:border-red-300 dark:hover:border-red-700 transition-colors disabled:opacity-50"
>
{options.map((target) => (
<option key={target.id} value={target.id}>
{target.name} ({target.email})
{target.name}
</option>
))}
</select>
@@ -196,28 +232,28 @@ export const ImpersonationBanner: React.FC = () => {
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"
className="h-8 flex items-center justify-center gap-1.5 px-3 rounded-lg bg-red-600 dark:bg-red-600/80 text-[11px] font-black uppercase tracking-wider text-white hover:bg-red-700 dark:hover:bg-red-500 transition-all disabled:opacity-50 shadow-sm shadow-red-900/10"
>
<XCircle size={15} />
Stop
<XCircle size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">Stop</span>
</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">
<div className="mt-1.5 pt-1.5 border-t border-red-200/50 dark:border-red-800/20 flex items-center gap-3 text-[10px] font-bold text-red-800 dark:text-red-300">
{loadingTargets ? (
<span className="inline-flex items-center gap-1">
<RefreshCw size={12} className="animate-spin" />
Loading users...
<span className="inline-flex items-center gap-1.5">
<RefreshCw size={10} className="animate-spin" />
Syncing targets...
</span>
) : null}
{error ? <span>{error}</span> : null}
{error ? <span className="truncate">{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"
className="px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40 border border-red-200 dark:border-red-700/50 hover:bg-red-200 transition-colors"
>
Retry
</button>
+19 -1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startOidcSignIn } from '../api';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -12,11 +13,24 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
isAuthenticated,
loading,
authEnabled,
oidcEnforced,
bootstrapRequired,
authOnboardingRequired,
user,
} = useAuth();
const OidcRedirect: React.FC<{ returnTo: string }> = ({ returnTo }) => {
useEffect(() => {
startOidcSignIn(returnTo);
}, [returnTo]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600 dark:text-gray-400">Redirecting to sign-in...</div>
</div>
);
};
if (loading || authEnabled === null) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -39,6 +53,10 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
if (bootstrapRequired) {
return <Navigate to="/register" replace />;
}
if (oidcEnforced) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
return <OidcRedirect returnTo={returnTo} />;
}
return <Navigate to="/login" replace />;
}
+28
View File
@@ -24,6 +24,10 @@ interface AuthContextType {
user: User | null;
loading: boolean;
authEnabled: boolean | null;
authMode: 'local' | 'hybrid' | 'oidc_enforced';
oidcEnabled: boolean;
oidcEnforced: boolean;
oidcProvider: string | null;
bootstrapRequired: boolean;
authOnboardingRequired: boolean;
authOnboardingMode: 'migration' | 'fresh' | null;
@@ -42,6 +46,10 @@ 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 [authMode, setAuthMode] = useState<'local' | 'hybrid' | 'oidc_enforced'>('local');
const [oidcEnabled, setOidcEnabled] = useState(false);
const [oidcEnforced, setOidcEnforced] = useState(false);
const [oidcProvider, setOidcProvider] = useState<string | null>(null);
const [bootstrapRequired, setBootstrapRequired] = useState(false);
const [authOnboardingRequired, setAuthOnboardingRequired] = useState(false);
const [authOnboardingMode, setAuthOnboardingMode] = useState<'migration' | 'fresh' | null>(null);
@@ -60,6 +68,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
: true;
setAuthEnabled(enabled);
localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled));
const nextAuthMode =
statusResponse?.authMode === 'hybrid' || statusResponse?.authMode === 'oidc_enforced'
? statusResponse.authMode
: 'local';
setAuthMode(nextAuthMode);
setOidcEnabled(Boolean(statusResponse?.oidcEnabled));
setOidcEnforced(Boolean(statusResponse?.oidcEnforced));
setOidcProvider(typeof statusResponse?.oidcProvider === 'string' ? statusResponse.oidcProvider : null);
setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired));
setAuthOnboardingRequired(Boolean(statusResponse?.authOnboardingRequired));
setAuthOnboardingMode(
@@ -77,6 +93,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const cachedAuthEnabled = localStorage.getItem(AUTH_ENABLED_CACHE_KEY);
if (cachedAuthEnabled === "false") {
setAuthEnabled(false);
setAuthMode('local');
setOidcEnabled(false);
setOidcEnforced(false);
setOidcProvider(null);
setBootstrapRequired(false);
setAuthOnboardingRequired(false);
setAuthOnboardingMode(null);
@@ -85,6 +105,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return;
}
setAuthEnabled(true);
setAuthMode('local');
setOidcEnabled(false);
setOidcEnforced(false);
setOidcProvider(null);
setBootstrapRequired(false);
setAuthOnboardingRequired(false);
setAuthOnboardingMode(null);
@@ -192,6 +216,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user,
loading,
authEnabled,
authMode,
oidcEnabled,
oidcEnforced,
oidcProvider,
bootstrapRequired,
authOnboardingRequired,
authOnboardingMode,
+83 -19
View File
@@ -16,6 +16,9 @@ export const Login: React.FC = () => {
login,
logout,
authEnabled,
oidcEnabled,
oidcEnforced,
oidcProvider,
bootstrapRequired,
authOnboardingRequired,
isAuthenticated,
@@ -25,8 +28,16 @@ export const Login: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryMustReset = searchParams.get('mustReset') === '1';
const oidcErrorCode = searchParams.get('oidcError');
const oidcErrorMessage = searchParams.get('oidcErrorMessage');
const oidcReturnTo = searchParams.get('returnTo') || '/';
const mustReset = Boolean(user?.mustResetPassword) || queryMustReset;
useEffect(() => {
if (!oidcErrorCode) return;
setError(oidcErrorMessage || 'OIDC sign-in failed');
}, [oidcErrorCode, oidcErrorMessage]);
useEffect(() => {
if (authLoading || authEnabled === null) return;
if (authOnboardingRequired) {
@@ -41,11 +52,28 @@ export const Login: React.FC = () => {
navigate('/register', { replace: true });
return;
}
if (oidcEnforced && !mustReset) {
if (!oidcErrorCode) {
api.startOidcSignIn(oidcReturnTo);
}
return;
}
if (isAuthenticated) {
if (mustReset) return;
navigate('/', { replace: true });
}
}, [authEnabled, authLoading, authOnboardingRequired, bootstrapRequired, isAuthenticated, mustReset, navigate]);
}, [
authEnabled,
authLoading,
authOnboardingRequired,
bootstrapRequired,
isAuthenticated,
mustReset,
navigate,
oidcEnforced,
oidcErrorCode,
oidcReturnTo,
]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -114,9 +142,13 @@ export const Login: 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">
{mustReset ? 'Reset your password' : 'Sign in to your account'}
{mustReset
? 'Reset your password'
: oidcEnforced
? `Sign in with ${oidcProvider || 'OIDC'}`
: 'Sign in to your account'}
</h2>
{!mustReset ? (
{!mustReset && !oidcEnforced ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
@@ -126,10 +158,14 @@ export const Login: React.FC = () => {
create a new account
</Link>
</p>
) : (
) : mustReset ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Your admin requires you to set a new password before using ExcaliDash.
</p>
) : (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
You will be redirected to {oidcProvider || 'your identity provider'}.
</p>
)}
</div>
<form className="mt-8 space-y-6" onSubmit={mustReset ? handleMustReset : handleSubmit}>
@@ -138,8 +174,19 @@ export const Login: React.FC = () => {
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
{!mustReset ? (
{oidcEnforced && !mustReset ? (
<div>
<button
type="button"
onClick={() => api.startOidcSignIn(oidcReturnTo)}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Continue with {oidcProvider || 'OIDC'}
</button>
</div>
) : (
<div className="rounded-md shadow-sm -space-y-px">
{!mustReset ? (
<>
<div>
<label htmlFor="email" className="sr-only">
@@ -174,7 +221,7 @@ export const Login: React.FC = () => {
/>
</div>
</>
) : (
) : (
<>
<div>
<label htmlFor="newPassword" className="sr-only">
@@ -211,10 +258,11 @@ export const Login: React.FC = () => {
/>
</div>
</>
)}
</div>
)}
</div>
)}
{!mustReset && (
{!mustReset && !oidcEnforced && (
<div className="flex justify-end">
<Link
to="/reset-password"
@@ -225,15 +273,31 @@ export const Login: React.FC = () => {
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{mustReset ? (loading ? 'Updating...' : 'Set new password') : (loading ? 'Signing in...' : 'Sign in')}
</button>
</div>
{(!oidcEnforced || mustReset) && (
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{mustReset
? (loading ? 'Updating...' : 'Set new password')
: (loading ? 'Signing in...' : 'Sign in')}
</button>
</div>
)}
{!mustReset && oidcEnabled && !oidcEnforced && (
<div>
<button
type="button"
onClick={() => api.startOidcSignIn('/')}
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-700 text-sm font-medium rounded-md text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Continue with {oidcProvider || 'OIDC'}
</button>
</div>
)}
{mustReset && (
<div className="text-center">
+6 -1
View File
@@ -12,6 +12,7 @@ export const Register: React.FC = () => {
const {
register,
authEnabled,
oidcEnforced,
bootstrapRequired,
authOnboardingRequired,
isAuthenticated,
@@ -25,6 +26,10 @@ export const Register: React.FC = () => {
navigate('/auth-setup', { replace: true });
return;
}
if (oidcEnforced) {
navigate('/login', { replace: true });
return;
}
if (!authEnabled) {
navigate('/', { replace: true });
return;
@@ -32,7 +37,7 @@ export const Register: React.FC = () => {
if (isAuthenticated) {
navigate('/', { replace: true });
}
}, [authEnabled, authLoading, authOnboardingRequired, isAuthenticated, navigate]);
}, [authEnabled, authLoading, authOnboardingRequired, isAuthenticated, navigate, oidcEnforced]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();