mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
feat(auth): split OIDC_ONLY into granular auth toggles
Replaces the coarse oidc_only + allow_registration settings with four independent toggles: password_login, password_registration, oidc_login, oidc_registration. Each can be enabled/disabled individually in Admin > Settings without affecting the others. - Add resolveAuthToggles() in authService.ts as the central resolver; falls back to legacy oidc_only/allow_registration keys when new keys are absent (backward compat) - OIDC_ONLY env var still works and overrides DB toggles for password_*, with a visual lock in the admin UI when active - Server enforces lockout prevention: cannot disable all login methods - oidc_login gate added to OIDC /login and /callback routes - Remove oidc_only toggle from OIDC settings panel; replaced by the granular toggles in the Settings tab - Add 6 new resolveAuthToggles() unit tests; fix AUTH-DB-033 error message assertion - Update OIDC_ONLY descriptions in README, docker-compose, Helm values, Unraid template, and .env.example to clarify override semantics Closes #492
This commit is contained in:
@@ -46,7 +46,6 @@ interface OidcConfig {
|
||||
client_secret: string
|
||||
client_secret_set: boolean
|
||||
display_name: string
|
||||
oidc_only: boolean
|
||||
discovery_url: string
|
||||
}
|
||||
|
||||
@@ -192,11 +191,16 @@ export default function AdminPage(): React.ReactElement {
|
||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' })
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
|
||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||
// Auth toggles
|
||||
const [passwordLogin, setPasswordLogin] = useState<boolean>(true)
|
||||
const [passwordRegistration, setPasswordRegistration] = useState<boolean>(true)
|
||||
const [oidcLogin, setOidcLogin] = useState<boolean>(true)
|
||||
const [oidcRegistration, setOidcRegistration] = useState<boolean>(true)
|
||||
const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState<boolean>(false)
|
||||
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
|
||||
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||
|
||||
// Invite links
|
||||
@@ -268,7 +272,12 @@ export default function AdminPage(): React.ReactElement {
|
||||
const loadAppConfig = async () => {
|
||||
try {
|
||||
const config = await authApi.getAppConfig()
|
||||
setAllowRegistration(config.allow_registration)
|
||||
setPasswordLogin(config.password_login ?? true)
|
||||
setPasswordRegistration(config.password_registration ?? config.allow_registration ?? true)
|
||||
setOidcLogin(config.oidc_login ?? true)
|
||||
setOidcRegistration(config.oidc_registration ?? config.allow_registration ?? true)
|
||||
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
|
||||
setOidcConfigured(config.oidc_configured ?? false)
|
||||
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
} catch (err: unknown) {
|
||||
@@ -286,12 +295,12 @@ export default function AdminPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRegistration = async (value) => {
|
||||
setAllowRegistration(value)
|
||||
const handleToggleAuthSetting = async (key: string, value: boolean, setter: (v: boolean) => void) => {
|
||||
setter(value)
|
||||
try {
|
||||
await authApi.updateAppSettings({ allow_registration: value })
|
||||
await authApi.updateAppSettings({ [key]: value })
|
||||
} catch (err: unknown) {
|
||||
setAllowRegistration(!value)
|
||||
setter(!value)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
@@ -792,28 +801,94 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="space-y-6">
|
||||
{/* Registration Toggle */}
|
||||
{/* Authentication Methods */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.allowRegistration')}</h2>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.authMethods')}</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="p-6 space-y-5">
|
||||
{envOverrideOidcOnly && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
{t('admin.envOverrideHint')}
|
||||
</p>
|
||||
)}
|
||||
{/* Password Login */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.allowRegistration')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.allowRegistrationHint')}</p>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.passwordLogin')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.passwordLoginHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleRegistration(!allowRegistration)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: allowRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
disabled={envOverrideOidcOnly || (!passwordLogin && !oidcLogin)}
|
||||
onClick={() => handleToggleAuthSetting('password_login', !passwordLogin, setPasswordLogin)}
|
||||
title={!passwordLogin && !oidcLogin ? t('admin.lockoutWarning') : undefined}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ background: passwordLogin ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: allowRegistration ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
style={{ transform: passwordLogin ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Password Registration */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.passwordRegistration')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.passwordRegistrationHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={envOverrideOidcOnly}
|
||||
onClick={() => handleToggleAuthSetting('password_registration', !passwordRegistration, setPasswordRegistration)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ background: passwordRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: passwordRegistration ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* SSO Login (only when OIDC configured) */}
|
||||
{oidcConfigured && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.oidcLogin')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcLoginHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={!passwordLogin && oidcLogin}
|
||||
onClick={() => handleToggleAuthSetting('oidc_login', !oidcLogin, setOidcLogin)}
|
||||
title={!passwordLogin && oidcLogin ? t('admin.lockoutWarning') : undefined}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ background: oidcLogin ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: oidcLogin ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* SSO Registration (only when OIDC configured) */}
|
||||
{oidcConfigured && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.oidcRegistration')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcRegistrationHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: oidcRegistration ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1036,29 +1111,11 @@ export default function AdminPage(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
{/* OIDC-only mode toggle */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.oidcOnlyMode')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcOnlyModeHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4"
|
||||
style={{ background: oidcConfig.oidc_only ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: oidcConfig.oidc_only ? 'translateX(20px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSavingOidc(true)
|
||||
try {
|
||||
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url }
|
||||
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, discovery_url: oidcConfig.discovery_url }
|
||||
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
||||
await adminApi.updateOidc(payload)
|
||||
toast.success(t('admin.oidcSaved'))
|
||||
|
||||
@@ -15,6 +15,11 @@ interface AppConfig {
|
||||
oidc_configured: boolean
|
||||
oidc_display_name?: string
|
||||
oidc_only_mode: boolean
|
||||
password_login: boolean
|
||||
password_registration: boolean
|
||||
oidc_login: boolean
|
||||
oidc_registration: boolean
|
||||
env_override_oidc_only: boolean
|
||||
}
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
@@ -104,7 +109,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
}
|
||||
}
|
||||
@@ -194,10 +199,10 @@ export default function LoginPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users)
|
||||
const showRegisterOption = (appConfig?.password_registration || !appConfig?.has_users || inviteValid) && (appConfig?.setup_complete !== false || !appConfig?.has_users)
|
||||
|
||||
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
|
||||
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
|
||||
const oidcOnly = !appConfig?.password_login && appConfig?.oidc_login && appConfig?.oidc_configured
|
||||
|
||||
const inputBase: React.CSSProperties = {
|
||||
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
||||
@@ -730,8 +735,8 @@ export default function LoginPage(): React.ReactElement {
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */}
|
||||
{appConfig?.oidc_configured && !oidcOnly && (
|
||||
{/* OIDC / SSO login button (only when OIDC is configured, oidc_login enabled, not in oidc-only mode) */}
|
||||
{appConfig?.oidc_configured && appConfig?.oidc_login && !oidcOnly && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||
|
||||
Reference in New Issue
Block a user