mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user