Files
picpeak/frontend/src/contexts/AdminAuthContext.tsx
T
Paul Nothaft e91c7deaa4 fix(oidc): local-credential lockout, session hydration, split-origin gaps (codex round 3)
- OIDC-owned accounts can never authenticate locally: the password
  login rejects auth_provider='oidc' rows outright (generic 401), and
  the super-admin password reset refuses them with a clear message —
  previously a reset would have minted a local password bypassing the
  IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
  AdminAuthContext hydrates user state from it: an SSO redirect
  establishes the session without any login JSON, which left the header
  identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
  (same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
  compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
  (leftJoin, prefixed columns, aliases) — the route change made the old
  mock throw, which read as "table missing, trust token"

Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
2026-07-16 10:07:59 +02:00

144 lines
4.1 KiB
TypeScript

import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { api } from '../config/api';
import { authService } from '../services';
import type { AdminUser } from '../types';
interface AdminAuthContextType {
isAuthenticated: boolean;
user: AdminUser | null;
login: (token: string, user: AdminUser) => void;
logout: () => void;
isLoading: boolean;
error: string | null;
mustChangePassword: boolean;
updatePasswordChanged: () => void;
updateUserProfile: (updates: Partial<AdminUser>) => void;
}
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
export const useAdminAuth = () => {
const context = useContext(AdminAuthContext);
if (!context) {
throw new Error('useAdminAuth must be used within an AdminAuthProvider');
}
return context;
};
interface AdminAuthProviderProps {
children: ReactNode;
}
export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<AdminUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mustChangePassword, setMustChangePassword] = useState(false);
useEffect(() => {
// Check if user has a valid token on mount
const checkAuth = async () => {
try {
const storedUser = sessionStorage.getItem('admin_user');
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch (err) {
sessionStorage.removeItem('admin_user');
}
}
const response = await api.get<{ valid: boolean; type: string; adminUsername?: string; user?: string; adminUser?: AdminUser | null }>(
'/auth/session'
);
if (response.data?.valid && response.data.type === 'admin') {
setIsAuthenticated(true);
// Redirect-established sessions (SSO, #798) never went through
// login(), so sessionStorage has no user — hydrate from the
// session payload. Server truth also refreshes stale local copies.
if (response.data.adminUser) {
setUser(response.data.adminUser);
sessionStorage.setItem('admin_user', JSON.stringify(response.data.adminUser));
}
} else {
sessionStorage.removeItem('admin_user');
setIsAuthenticated(false);
setUser(null);
}
} catch (error) {
// Auth check failed - user needs to login
sessionStorage.removeItem('admin_user');
setIsAuthenticated(false);
setUser(null);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = (_token: string, user: AdminUser) => {
// Token is already stored in cookie by authService
setUser(user);
setError(null);
setIsAuthenticated(true);
setMustChangePassword(user.mustChangePassword || false);
sessionStorage.setItem('admin_user', JSON.stringify(user));
};
const logout = () => {
sessionStorage.removeItem('admin_user');
authService.adminLogout();
setIsAuthenticated(false);
setUser(null);
setMustChangePassword(false);
};
const updatePasswordChanged = () => {
setMustChangePassword(false);
if (user) {
setUser({
...user,
mustChangePassword: false
});
sessionStorage.setItem('admin_user', JSON.stringify({
...user,
mustChangePassword: false
}));
}
};
const updateUserProfile = (updates: Partial<AdminUser>) => {
setUser((prev) => {
if (!prev) {
return prev;
}
const nextUser = { ...prev, ...updates };
sessionStorage.setItem('admin_user', JSON.stringify(nextUser));
return nextUser;
});
};
return (
<AdminAuthContext.Provider
value={{
isAuthenticated,
user,
login,
logout,
isLoading,
error,
mustChangePassword,
updatePasswordChanged,
updateUserProfile,
}}
>
{children}
</AdminAuthContext.Provider>
);
};