Patch: OIDC polish (#636)

- Added two env vars for OIDC login: 
- HIDE_LOCAL_AUTH - Remove the "password" option on login page when OIDC
enabled
  - OIDC_AUTO_REDIRECT - Immediately launch OIDC provider page
- Improved UX for initial OIDC setup, including creating a local admin
user
- Added callback URL label to OIDC setup page
- Fix Qbittorrent save path bug
This commit is contained in:
Alex
2026-02-21 11:51:11 +00:00
committed by GitHub
parent fdd46852f2
commit 014fc38b48
26 changed files with 369 additions and 89 deletions
+6
View File
@@ -150,9 +150,12 @@ function App() {
username,
displayName,
oidcButtonLabel,
hideLocalAuth,
oidcAutoRedirect,
loginError,
isLoggingIn,
setIsAuthenticated,
refreshAuth,
handleLogin,
handleLogout,
} = useAuth({
@@ -1409,6 +1412,7 @@ function App() {
onClose={() => setSettingsOpen(false)}
onShowToast={showToast}
onSettingsSaved={handleSettingsSaved}
onRefreshAuth={refreshAuth}
/>
<SelfSettingsModal
@@ -1497,6 +1501,8 @@ function App() {
isLoading={isLoggingIn}
authMode={authMode}
oidcButtonLabel={oidcButtonLabel}
hideLocalAuth={hideLocalAuth}
oidcAutoRedirect={oidcAutoRedirect}
/>
)
}
+37 -17
View File
@@ -1,4 +1,5 @@
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
@@ -9,6 +10,8 @@ interface LoginFormProps {
autoFocus?: boolean;
authMode?: string;
oidcButtonLabel?: string | null;
hideLocalAuth?: boolean;
oidcAutoRedirect?: boolean;
}
const EyeIcon = () => (
@@ -219,9 +222,13 @@ export const LoginForm = ({
autoFocus = true,
authMode,
oidcButtonLabel,
hideLocalAuth = false,
oidcAutoRedirect = false,
}: LoginFormProps) => {
const isOidc = authMode === 'oidc';
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
const [searchParams] = useSearchParams();
const oidcError = searchParams.get('oidc_error');
// Auto-expand password form if there's an error (likely from a password attempt)
useEffect(() => {
@@ -230,6 +237,13 @@ export const LoginForm = ({
}
}, [error, isOidc]);
// Auto-redirect to OIDC provider when enabled and no errors present
useEffect(() => {
if (oidcAutoRedirect && isOidc && !error && !oidcError) {
window.location.href = withBasePath('/api/auth/oidc/login');
}
}, [oidcAutoRedirect, isOidc, error, oidcError]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
@@ -245,11 +259,13 @@ export const LoginForm = ({
}
};
const displayError = oidcError || error;
return (
<div>
{error && (
{displayError && (
<div className="mb-4 p-3 rounded-lg text-sm bg-red-600 text-white">
{error}
{displayError}
</div>
)}
@@ -262,22 +278,26 @@ export const LoginForm = ({
{oidcButtonLabel || 'Sign in with OIDC'}
</a>
<div className="flex items-center mt-5 mb-2">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<button
type="button"
onClick={() => setShowPasswordLogin((prev) => !prev)}
className="px-3 text-sm opacity-60 hover:opacity-100 transition-opacity"
>
{showPasswordLogin ? 'Hide' : 'Use password'}
</button>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
</div>
{!hideLocalAuth && (
<>
<div className="flex items-center mt-5 mb-2">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<button
type="button"
onClick={() => setShowPasswordLogin((prev) => !prev)}
className="px-3 text-sm opacity-60 hover:opacity-100 transition-opacity"
>
{showPasswordLogin ? 'Hide' : 'Use password'}
</button>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
</div>
{showPasswordLogin && (
<div className="pt-2">
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={true} />
</div>
{showPasswordLogin && (
<div className="pt-2">
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={true} />
</div>
)}
</>
)}
</>
) : (
@@ -53,6 +53,7 @@ interface SettingsContentProps {
authMode?: string;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onRefreshOverrideSummary?: () => void;
onRefreshAuth?: () => Promise<void>;
};
}
@@ -407,6 +408,7 @@ export const SettingsContent = ({
authMode: customFieldContext?.authMode,
onShowToast: customFieldContext?.onShowToast,
onRefreshOverrideSummary: customFieldContext?.onRefreshOverrideSummary,
onRefreshAuth: customFieldContext?.onRefreshAuth,
})
: renderField(
field,
@@ -13,9 +13,10 @@ interface SettingsModalProps {
onClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSettingsSaved?: () => void;
onRefreshAuth?: () => Promise<void>;
}
export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettingsSaved }: SettingsModalProps) => {
export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettingsSaved, onRefreshAuth }: SettingsModalProps) => {
const {
tabs,
groups,
@@ -301,6 +302,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
authMode: usersAuthMode,
onShowToast,
onRefreshOverrideSummary: handleRefreshCurrentTabOverrideSummary,
onRefreshAuth,
}}
/>
))
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { getAdminUsers } from '../../../services/api';
import { CustomSettingsFieldRendererProps } from './types';
export const OidcAdminHint = ({ field }: CustomSettingsFieldRendererProps) => {
const [needsAdmin, setNeedsAdmin] = useState<boolean | null>(null);
useEffect(() => {
let cancelled = false;
getAdminUsers()
.then((users) => {
if (!cancelled) {
setNeedsAdmin(!users.some(u => u.role === 'admin' && u.auth_source === 'builtin'));
}
})
.catch(() => {
if (!cancelled) {
setNeedsAdmin(true);
}
});
return () => { cancelled = true; };
}, []);
if (!needsAdmin) return null;
return (
<div className="text-sm px-3 py-2 rounded-lg bg-amber-500/15 text-amber-700 dark:text-amber-300">
{field.label}
</div>
);
};
@@ -0,0 +1,26 @@
import { CustomSettingsFieldRendererProps } from './types';
export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
return (
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<div
className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
style={{ background: 'var(--bg-soft)' }}
>
docker-compose.yml
</div>
<pre
className="px-3 py-3 text-xs overflow-x-auto"
style={{ background: 'var(--bg-soft)' }}
>
<code>
<span className="opacity-60">environment:</span>{'\n'}
{' '}- <span className="text-blue-400">HIDE_LOCAL_AUTH</span>=<span className="text-green-400">true</span>
{' '}<span className="opacity-40"># Hide the local login form</span>{'\n'}
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=<span className="text-green-400">true</span>
{' '}<span className="opacity-40"># Skip login page, redirect straight to OIDC</span>
</code>
</pre>
</div>
);
};
@@ -0,0 +1,15 @@
import { CustomSettingsFieldRendererProps } from './types';
const interpolate = (text: string): string =>
text.replace(/\{origin\}/g, window.location.origin);
export const SettingsLabel = ({ field }: CustomSettingsFieldRendererProps) => {
return (
<div className="text-sm px-3 py-2 rounded-lg bg-sky-500/20">
{field.label && <span className="opacity-60">{field.label} </span>}
{field.description && (
<code className="font-mono text-xs">{interpolate(field.description)}</code>
)}
</div>
);
};
@@ -21,6 +21,7 @@ export const UsersManagementField = ({
authMode,
onShowToast,
onRefreshOverrideSummary,
onRefreshAuth,
}: CustomSettingsFieldRendererProps) => {
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
const activeEditRequestIdRef = useRef(0);
@@ -111,6 +112,7 @@ export const UsersManagementField = ({
};
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode || 'none');
const needsLocalAdmin = !users.some(u => u.role === 'admin' && u.auth_source === 'builtin');
const handleBackToList = () => {
onUiStateChange('routeKind', 'list');
@@ -129,6 +131,7 @@ export const UsersManagementField = ({
const ok = await createUser();
if (ok) {
onRefreshOverrideSummary?.();
onRefreshAuth?.();
backToList();
}
};
@@ -213,9 +216,10 @@ export const UsersManagementField = ({
const ok = await deleteUser(userId);
if (ok) {
onRefreshOverrideSummary?.();
onRefreshAuth?.();
}
return ok;
}, [deleteUser, onRefreshOverrideSummary]);
}, [deleteUser, onRefreshAuth, onRefreshOverrideSummary]);
useEffect(() => {
if (route.kind !== 'edit-overrides') {
@@ -263,7 +267,13 @@ export const UsersManagementField = ({
loadingUsers={loading}
loadError={loadError}
onRetryLoadUsers={() => void fetchUsers({ force: true })}
onCreate={openCreate}
onCreate={() => {
if (needsLocalAdmin) {
setCreateForm({ ...createForm, role: 'admin' });
}
openCreate();
}}
needsLocalAdmin={needsLocalAdmin}
showCreateForm={route.kind === 'create'}
createForm={createForm}
onCreateFormChange={setCreateForm}
@@ -1,5 +1,8 @@
import { ComponentType, ReactNode } from 'react';
import { OidcAdminHint } from './OidcAdminHint';
import { OidcEnvInfo } from './OidcEnvInfo';
import { RequestPolicyGridField } from './RequestPolicyGridField';
import { SettingsLabel } from './SettingsLabel';
import { UsersManagementField } from './UsersManagementField';
import {
CustomSettingsFieldLayout,
@@ -39,6 +42,15 @@ const CUSTOM_FIELD_DEFINITIONS: Record<string, CustomFieldDefinition> = {
request_policy_grid: {
renderer: RequestPolicyGridField,
},
settings_label: {
renderer: SettingsLabel,
},
oidc_admin_hint: {
renderer: OidcAdminHint,
},
oidc_env_info: {
renderer: OidcEnvInfo,
},
};
export const renderCustomSettingsField = (
@@ -13,6 +13,7 @@ export interface CustomSettingsFieldRendererProps {
authMode?: string;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onRefreshOverrideSummary?: () => void;
onRefreshAuth?: () => Promise<void>;
}
export interface CustomSettingsFieldLayout {
@@ -336,6 +336,7 @@ interface UserCreateCardProps {
onChange: (form: CreateUserFormState) => void;
creating: boolean;
isFirstUser: boolean;
needsLocalAdmin?: boolean;
onSubmit: () => void;
onCancel: () => void;
}
@@ -345,6 +346,7 @@ export const UserCreateCard = ({
onChange,
creating,
isFirstUser,
needsLocalAdmin = false,
onSubmit,
onCancel,
}: UserCreateCardProps) => {
@@ -368,6 +370,11 @@ export const UserCreateCard = ({
This will be the first account and will be created as admin.
</p>
)}
{needsLocalAdmin && !isFirstUser && (
<p className="text-xs text-zinc-500">
An admin account is required before OIDC can be enabled.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{renderTextField(usernameField, form.username, (value) => onChange({ ...form, username: value }))}
@@ -12,6 +12,7 @@ interface UserListViewProps {
loadingUsers: boolean;
loadError: string | null;
onRetryLoadUsers: () => void;
needsLocalAdmin: boolean;
onCreate: () => void;
showCreateForm: boolean;
createForm: CreateUserFormState;
@@ -46,6 +47,7 @@ export const UserListView = ({
loadingUsers,
loadError,
onRetryLoadUsers,
needsLocalAdmin,
onCreate,
showCreateForm,
createForm,
@@ -225,6 +227,7 @@ export const UserListView = ({
onChange={onCreateFormChange}
creating={creating}
isFirstUser={isFirstUser}
needsLocalAdmin={needsLocalAdmin}
onSubmit={onCreateSubmit}
onCancel={onCancelCreate}
/>
+20
View File
@@ -18,9 +18,12 @@ interface UseAuthReturn {
username: string | null;
displayName: string | null;
oidcButtonLabel: string | null;
hideLocalAuth: boolean;
oidcAutoRedirect: boolean;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
refreshAuth: () => Promise<void>;
handleLogin: (credentials: LoginCredentials) => Promise<void>;
handleLogout: () => Promise<void>;
}
@@ -38,6 +41,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const [username, setUsername] = useState<string | null>(null);
const [displayName, setDisplayName] = useState<string | null>(null);
const [oidcButtonLabel, setOidcButtonLabel] = useState<string | null>(null);
const [hideLocalAuth, setHideLocalAuth] = useState<boolean>(false);
const [oidcAutoRedirect, setOidcAutoRedirect] = useState<boolean>(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
@@ -49,6 +54,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
setUsername(response.username || null);
setDisplayName(response.display_name || null);
setOidcButtonLabel(response.oidc_button_label || null);
setHideLocalAuth(response.hide_local_auth || false);
setOidcAutoRedirect(response.oidc_auto_redirect || false);
}, []);
const refreshSocketSession = useCallback(() => {
@@ -107,6 +114,14 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
};
}, [applyAuthResponse]);
const refreshAuth = useCallback(async () => {
try {
applyAuthResponse(await checkAuth());
} catch (error) {
console.error('Auth refresh failed:', error);
}
}, [applyAuthResponse]);
const handleLogin = useCallback(async (credentials: LoginCredentials) => {
setIsLoggingIn(true);
setLoginError(null);
@@ -145,6 +160,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
setUsername(null);
setDisplayName(null);
setOidcButtonLabel(null);
setHideLocalAuth(false);
setOidcAutoRedirect(false);
onLogoutSuccess?.();
navigate('/login', { replace: true });
} catch (error) {
@@ -162,9 +179,12 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
username,
displayName,
oidcButtonLabel,
hideLocalAuth,
oidcAutoRedirect,
loginError,
isLoggingIn,
setIsAuthenticated,
refreshAuth,
handleLogin,
handleLogout,
};
+4 -2
View File
@@ -8,9 +8,11 @@ interface LoginPageProps {
isLoading: boolean;
authMode?: string;
oidcButtonLabel?: string | null;
hideLocalAuth?: boolean;
oidcAutoRedirect?: boolean;
}
export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel }: LoginPageProps) => {
export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel, hideLocalAuth, oidcAutoRedirect }: LoginPageProps) => {
const logoUrl = withBasePath('/logo.png');
return (
@@ -30,7 +32,7 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel
<div className="text-center mb-5">
<img src={logoUrl} alt="Logo" className="mx-auto w-12 h-12" />
</div>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} oidcButtonLabel={oidcButtonLabel} />
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} oidcButtonLabel={oidcButtonLabel} hideLocalAuth={hideLocalAuth} oidcAutoRedirect={oidcAutoRedirect} />
</div>
</div>
</div>
+2
View File
@@ -261,6 +261,8 @@ export interface AuthResponse {
error?: string;
logout_url?: string;
oidc_button_label?: string;
hide_local_auth?: boolean;
oidc_auto_redirect?: boolean;
}
// Type guard to check if a book is from a metadata provider