Patch: Multi-user and OIDC polish (#612)

- Moved backend OIDC functionality to external library Authlib to help
maintainability
- Separated User settings UI into individual components, allowing for
standard settings UI decorator components to be used.
- Added full support for reverse proxy and CWA users alongside local and
OIDC
- Added mapping and syncing functionality for OIDC, CWA and reverse
proxy users
- Added per-user settings into the app-wide config system. Each config
can be declared as user-overrideable, and app-wide functionality can now
receive user-specific options via standard config calls.
- Added per-user audiobook destination config
- Updated login modal UI for simplified login, plus custom labels for
OIDC login
- Added user visibility in header dropdown
- Unified "restrict settings to admin" to use app-wide user roles.
This commit is contained in:
Alex
2026-02-12 14:38:28 +00:00
committed by GitHub
parent 2d2f54729f
commit 5bed0b20f4
64 changed files with 5775 additions and 2816 deletions
+11 -73
View File
@@ -19,7 +19,6 @@ import { SearchSection } from './components/SearchSection';
import { AdvancedFilters } from './components/AdvancedFilters';
import { ResultsSection } from './components/ResultsSection';
import { DetailsModal } from './components/DetailsModal';
import { EmailRecipientModal } from './components/EmailRecipientModal';
import { ReleaseModal } from './components/ReleaseModal';
import { DownloadsSidebar } from './components/DownloadsSidebar';
import { ToastContainer } from './components/ToastContainer';
@@ -30,7 +29,6 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner';
import { OnboardingModal } from './components/OnboardingModal';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { UserCancelledError, isUserCancelledError } from './utils/errors';
import { withBasePath } from './utils/basePath';
import { SearchModeProvider } from './contexts/SearchModeContext';
import './styles.css';
@@ -80,6 +78,9 @@ function App() {
authChecked,
isAdmin,
authMode,
username,
displayName,
oidcButtonLabel,
loginError,
isLoggingIn,
setIsAuthenticated,
@@ -145,49 +146,6 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [configBannerOpen, setConfigBannerOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
const [emailRecipientModalOpen, setEmailRecipientModalOpen] = useState(false);
const emailRecipientPromiseRef = useRef<{
resolve: (nickname: string) => void;
reject: (error: Error) => void;
} | null>(null);
const openEmailRecipientPicker = useCallback(async (): Promise<string> => {
const recipients = config?.email_recipients ?? [];
if (!config || config.books_output_mode !== 'email') {
return '';
}
if (recipients.length === 0) {
throw new Error('No email recipients configured');
}
if (recipients.length === 1) {
return recipients[0]?.nickname ?? '';
}
if (emailRecipientPromiseRef.current) {
throw new Error('Email recipient picker already open');
}
setEmailRecipientModalOpen(true);
return new Promise((resolve, reject) => {
emailRecipientPromiseRef.current = { resolve, reject };
});
}, [config]);
const handleEmailRecipientSelect = useCallback((nickname: string) => {
const pending = emailRecipientPromiseRef.current;
emailRecipientPromiseRef.current = null;
setEmailRecipientModalOpen(false);
pending?.resolve(nickname);
}, []);
const handleEmailRecipientCancel = useCallback(() => {
const pending = emailRecipientPromiseRef.current;
emailRecipientPromiseRef.current = null;
setEmailRecipientModalOpen(false);
pending?.reject(new UserCancelledError());
}, []);
// Expose debug function to trigger onboarding from browser console
useEffect(() => {
@@ -482,18 +440,9 @@ function App() {
// Download book
const handleDownload = async (book: Book): Promise<void> => {
try {
let emailRecipient: string | undefined;
if (config?.books_output_mode === 'email' && contentType === 'ebook') {
emailRecipient = await openEmailRecipientPicker();
}
await downloadBook(book.id, emailRecipient);
await downloadBook(book.id);
await fetchStatus();
} catch (error) {
if (isUserCancelledError(error)) {
// Important: rethrow so buttons can reset their "Queuing..." state.
throw error;
}
console.error('Download failed:', error);
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
throw error;
@@ -546,11 +495,6 @@ function App() {
// Handle download from ReleaseModal
const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => {
try {
let emailRecipient: string | undefined;
if (config?.books_output_mode === 'email' && releaseContentType === 'ebook') {
emailRecipient = await openEmailRecipientPicker();
}
trackRelease(book.id, release.source_id);
await downloadRelease({
@@ -572,13 +516,9 @@ function App() {
series_name: book.series_name,
series_position: book.series_position,
subtitle: book.subtitle,
email_recipient: emailRecipient,
});
await fetchStatus();
} catch (error) {
if (isUserCancelledError(error)) {
throw error;
}
console.error('Release download failed:', error);
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
throw error;
@@ -630,13 +570,16 @@ function App() {
searchInput={searchInput}
onSearchChange={setSearchInput}
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
onSettingsClick={isAdmin ? () => {
onSettingsClick={() => {
if (config?.settings_enabled) {
setSettingsOpen(true);
} else {
setConfigBannerOpen(true);
}
} : undefined}
}}
isAdmin={isAdmin}
username={username}
displayName={displayName}
statusCounts={statusCounts}
onLogoClick={() => handleResetSearch(config)}
authRequired={authRequired}
@@ -750,13 +693,6 @@ function App() {
/>
)}
<EmailRecipientModal
isOpen={emailRecipientModalOpen}
recipients={config?.email_recipients ?? []}
onSelect={handleEmailRecipientSelect}
onCancel={handleEmailRecipientCancel}
/>
</main>
<Footer
@@ -776,6 +712,7 @@ function App() {
<SettingsModal
isOpen={settingsOpen}
authMode={authMode}
onClose={() => setSettingsOpen(false)}
onShowToast={showToast}
onSettingsSaved={handleSettingsSaved}
@@ -856,6 +793,7 @@ function App() {
error={loginError}
isLoading={isLoggingIn}
authMode={authMode}
oidcButtonLabel={oidcButtonLabel}
/>
)
}
@@ -1,187 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { EmailRecipient } from '../types';
interface EmailRecipientModalProps {
isOpen: boolean;
recipients: EmailRecipient[];
onSelect: (nickname: string) => void;
onCancel: () => void;
}
export const EmailRecipientModal = ({ isOpen, recipients, onSelect, onCancel }: EmailRecipientModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const [selectedNickname, setSelectedNickname] = useState<string | null>(null);
// Reset selection when modal opens/closes
useEffect(() => {
if (isOpen) setSelectedNickname(null);
}, [isOpen]);
const handleCancel = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
setTimeout(() => {
onCancel();
setIsClosing(false);
}, 150);
}, [onCancel, isClosing]);
const handleSend = useCallback(() => {
if (isClosing || !selectedNickname) return;
setIsClosing(true);
setTimeout(() => {
onSelect(selectedNickname);
setIsClosing(false);
}, 150);
}, [onSelect, selectedNickname, isClosing]);
// ESC to cancel
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleCancel]);
// Prevent body scroll while open
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
if (!isOpen) return null;
const titleId = 'email-recipient-modal-title';
return (
<div
className="modal-overlay active sm:px-6 sm:py-6"
onClick={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
className={`details-container w-full sm:max-w-md h-full sm:h-auto pointer-events-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex h-full sm:h-auto flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-[var(--border-muted)] bg-[var(--bg)] text-[var(--text)] shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4">
<div className="flex-1 space-y-1">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Output</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug">
Send via Email
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
Choose a recipient for this download.
</p>
</div>
<button
type="button"
onClick={handleCancel}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
aria-label="Cancel"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
{recipients.length === 0 ? (
<div className="rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3 text-sm">
No recipients configured.
</div>
) : (
<div className="space-y-2">
{recipients.map((r) => {
const isSelected = selectedNickname === r.nickname;
return (
<button
key={`${r.nickname}:${r.email}`}
type="button"
onClick={() => setSelectedNickname(isSelected ? null : r.nickname)}
className={`w-full text-left rounded-2xl border px-4 py-3 transition-colors hover-action ${
isSelected
? 'border-sky-500 bg-sky-500/10'
: 'border-[var(--border-muted)] bg-[var(--bg-soft)]'
}`}
>
<div className="flex items-center gap-3">
<div
className={`flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected ? 'border-sky-500 bg-sky-500' : 'border-gray-400 dark:border-gray-500'
}`}
>
{isSelected && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="min-w-0">
<div className="font-medium truncate">{r.nickname}</div>
<div className="text-xs opacity-70 truncate">{r.email}</div>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Desktop footer: small Send + Cancel buttons */}
<footer className="hidden sm:flex border-t border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4 justify-end gap-2">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSend}
disabled={!selectedNickname}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</footer>
{/* Mobile: full-width Send button, slides up when a recipient is selected */}
{selectedNickname && (
<div
className="sm:hidden flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
type="button"
onClick={handleSend}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700"
>
Send
</button>
</div>
)}
</div>
</div>
</div>
);
};
+41 -14
View File
@@ -26,10 +26,13 @@ interface HeaderProps {
isLoading?: boolean;
onDownloadsClick?: () => void;
onSettingsClick?: () => void;
isAdmin?: boolean;
statusCounts?: StatusCounts;
onLogoClick?: () => void;
authRequired?: boolean;
isAuthenticated?: boolean;
username?: string | null;
displayName?: string | null;
onLogout?: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info', persistent?: boolean) => string;
onRemoveToast?: (id: string) => void;
@@ -50,10 +53,13 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
isLoading = false,
onDownloadsClick,
onSettingsClick,
isAdmin = false,
statusCounts = { ongoing: 0, completed: 0, errored: 0 },
onLogoClick,
authRequired = false,
isAuthenticated = false,
username,
displayName,
onLogout,
onShowToast,
onRemoveToast,
@@ -311,11 +317,14 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{onSettingsClick && (
<button
type="button"
onClick={() => {
onClick={isAdmin ? () => {
closeDropdown();
onSettingsClick();
}}
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3"
} : undefined}
disabled={!isAdmin}
className={`w-full text-left px-4 py-2 transition-colors flex items-center gap-3 ${
isAdmin ? 'hover-surface' : 'opacity-40 cursor-not-allowed'
}`}
>
<svg
className="w-5 h-5"
@@ -409,18 +418,36 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
</>
)}
{/* Logout Button */}
{authRequired && isAuthenticated && onLogout && (
<button
type="button"
onClick={handleLogout}
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-red-600 dark:text-red-400"
{/* User Footer */}
{authRequired && isAuthenticated && username && (
<div
className="border-t"
style={{ borderColor: 'var(--border-muted)' }}
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
<span>Sign Out</span>
</button>
<div className="px-4 py-3 flex items-center gap-2.5">
<span
className="w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0 uppercase"
style={{ backgroundColor: 'var(--hover-surface)', color: 'var(--text)' }}
>
{(displayName || username).slice(0, 2)}
</span>
<div className="flex-1 min-w-0 truncate text-sm font-medium">
{displayName || username}
</div>
{onLogout && (
<button
type="button"
onClick={handleLogout}
className="shrink-0 p-2 rounded-full hover-action transition-colors text-red-600 dark:text-red-400"
title="Sign Out"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
</button>
)}
</div>
</div>
)}
</div>
</div>
+191 -155
View File
@@ -8,6 +8,7 @@ interface LoginFormProps {
isLoading?: boolean;
autoFocus?: boolean;
authMode?: string;
oidcButtonLabel?: string | null;
}
const EyeIcon = () => (
@@ -49,13 +50,15 @@ const EyeSlashIcon = () => (
</svg>
);
export const LoginForm = ({
const PasswordLoginForm = ({
onSubmit,
error = null,
isLoading = false,
autoFocus = true,
authMode,
}: LoginFormProps) => {
isLoading,
autoFocus,
}: {
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
autoFocus: boolean;
}) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(true);
@@ -69,6 +72,164 @@ export const LoginForm = ({
}
}, [autoFocus]);
const handleUsernameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
passwordRef.current?.focus();
}
};
return (
<form
method="post"
action={withBasePath('/api/login')}
autoComplete="on"
id="login-form"
name="login"
data-form-type="login"
onSubmit={onSubmit}
>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium mb-2">
Username
</label>
<input
ref={usernameRef}
type="text"
id="username"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="next"
value={username}
onChange={(event) => setUsername(event.target.value)}
onKeyDown={handleUsernameKeyDown}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<div className="relative">
<input
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autoComplete="current-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="go"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="remember-me"
name="remember_me"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
Remember me for 7 days
</label>
</div>
<button
type="submit"
name="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
aria-label="Sign in"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</form>
);
};
export const LoginForm = ({
onSubmit,
error = null,
isLoading = false,
autoFocus = true,
authMode,
oidcButtonLabel,
}: LoginFormProps) => {
const isOidc = authMode === 'oidc';
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
// Auto-expand password form if there's an error (likely from a password attempt)
useEffect(() => {
if (error && isOidc) {
setShowPasswordLogin(true);
}
}, [error, isOidc]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
@@ -79,18 +240,11 @@ export const LoginForm = ({
onSubmit({
username: usernameValue,
password: passwordValue,
remember_me: rememberMe,
remember_me: formData.has('remember_me'),
});
}
};
const handleUsernameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
passwordRef.current?.focus();
}
};
return (
<div>
{error && (
@@ -98,154 +252,36 @@ export const LoginForm = ({
{error}
</div>
)}
<form
method="post"
action={withBasePath('/api/login')}
autoComplete="on"
id="login-form"
name="login"
data-form-type="login"
onSubmit={handleSubmit}
>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium mb-2">
Username
</label>
<input
ref={usernameRef}
type="text"
id="username"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="next"
value={username}
onChange={(event) => setUsername(event.target.value)}
onKeyDown={handleUsernameKeyDown}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<div className="relative">
<input
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autoComplete="current-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="go"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="remember-me"
name="remember_me"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
Remember me for 7 days
</label>
</div>
<button
type="submit"
name="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
aria-label="Sign in"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</form>
{authMode === 'oidc' && (
{isOidc ? (
<>
<div className="flex items-center my-4">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<span className="px-3 text-sm opacity-60">or</span>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
</div>
<a
href={withBasePath('/api/auth/oidc/login')}
className="w-full py-2.5 px-4 rounded-lg font-medium text-center transition-colors border block"
style={{
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white text-center transition-colors block bg-sky-700 hover:bg-sky-800"
>
Sign in with OIDC
{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>
{showPasswordLogin && (
<div className="pt-2">
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={true} />
</div>
)}
</>
) : (
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={autoFocus} />
)}
</div>
);
@@ -41,6 +41,8 @@ interface SettingsContentProps {
isSaving: boolean;
hasChanges: boolean;
isUniversalMode?: boolean; // Whether app is in Universal search mode
overrideSummary?: Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>;
embedded?: boolean;
}
function evaluateShowWhenCondition(
@@ -245,15 +247,20 @@ export const SettingsContent = ({
isSaving,
hasChanges,
isUniversalMode = true,
overrideSummary,
embedded = false,
}: SettingsContentProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
// Reset scroll position when tab changes
useEffect(() => {
if (embedded) {
return;
}
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [tab.name]);
}, [embedded, tab.name]);
// Memoize the visible fields to avoid recalculating on every render
const visibleFields = useMemo(
@@ -261,6 +268,77 @@ export const SettingsContent = ({
[tab.fields, values, isUniversalMode]
);
const renderedFields = (
<div className="space-y-5">
{visibleFields.map((field) => {
const disabledState = getDisabledState(field, values);
const fieldOverrideSummary = overrideSummary?.[field.key];
return (
<FieldWrapper
key={`${tab.name}-${field.key}`}
field={field}
disabledOverride={disabledState.disabled}
disabledReasonOverride={disabledState.reason}
userOverrideCount={fieldOverrideSummary?.count}
userOverrideDetails={fieldOverrideSummary?.users}
>
{renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled,
values
)}
</FieldWrapper>
);
})}
</div>
);
const saveButton = hasChanges ? (
<button
onClick={onSave}
disabled={isSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
) : null;
if (embedded) {
return (
<div className="space-y-5">
{renderedFields}
{saveButton}
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Scrollable content area */}
@@ -269,28 +347,7 @@ export const SettingsContent = ({
className="flex-1 overflow-y-auto p-6"
style={{ paddingBottom: hasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
>
<div className="space-y-5">
{visibleFields.map((field) => {
const disabledState = getDisabledState(field, values);
return (
<FieldWrapper
key={`${tab.name}-${field.key}`}
field={field}
disabledOverride={disabledState.disabled}
disabledReasonOverride={disabledState.reason}
>
{renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled,
values
)}
</FieldWrapper>
);
})}
</div>
{renderedFields}
</div>
{/* Save button - only visible when there are changes */}
@@ -299,37 +356,7 @@ export const SettingsContent = ({
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
onClick={onSave}
disabled={isSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
{saveButton}
</div>
)}
</div>
@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useSettings } from '../../hooks/useSettings';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { getAdminSettingsOverridesSummary } from '../../services/api';
import { SettingsHeader } from './SettingsHeader';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsContent } from './SettingsContent';
@@ -8,12 +9,13 @@ import { UsersPanel } from './UsersPanel';
interface SettingsModalProps {
isOpen: boolean;
authMode: string;
onClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSettingsSaved?: () => void;
}
export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }: SettingsModalProps) => {
export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettingsSaved }: SettingsModalProps) => {
const {
tabs,
groups,
@@ -35,6 +37,10 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
const [isMobile, setIsMobile] = useState(false);
const [showMobileDetail, setShowMobileDetail] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [usersSubpageState, setUsersSubpageState] = useState<{ title: string; onBack: () => void } | null>(null);
const [tabOverrideSummaries, setTabOverrideSummaries] = useState<
Record<string, Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>>
>({});
// Track previous isOpen state to detect modal open transition
const prevIsOpenRef = useRef(false);
@@ -91,9 +97,44 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
if (isOpen) {
setShowMobileDetail(false);
setIsClosing(false);
setUsersSubpageState(null);
setTabOverrideSummaries({});
}
}, [isOpen]);
useEffect(() => {
if (selectedTab !== 'users') {
setUsersSubpageState(null);
}
}, [selectedTab]);
useEffect(() => {
if (!isOpen || !selectedTab || selectedTab === 'users') {
return;
}
let cancelled = false;
getAdminSettingsOverridesSummary(selectedTab)
.then((data) => {
if (cancelled) return;
setTabOverrideSummaries((prev) => ({
...prev,
[selectedTab]: data.keys || {},
}));
})
.catch(() => {
if (cancelled) return;
setTabOverrideSummaries((prev) => ({
...prev,
[selectedTab]: {},
}));
});
return () => {
cancelled = true;
};
}, [isOpen, selectedTab]);
// Reset to first tab when modal transitions from closed to open
useEffect(() => {
const justOpened = isOpen && !prevIsOpenRef.current;
@@ -142,9 +183,17 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
if (!selectedTab) {
return { success: false, message: 'No tab selected' };
}
if (selectedTab === 'security' && actionKey === 'open_users_tab') {
setSelectedTab('users');
if (isMobile) {
setShowMobileDetail(true);
}
return { success: true, message: 'Opening Users tab...' };
}
return executeAction(selectedTab, actionKey);
},
[selectedTab, executeAction]
[selectedTab, executeAction, isMobile, setSelectedTab]
);
// Memoize the field change handler to prevent creating new functions on every render
@@ -194,6 +243,37 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
const currentTab = tabs.find((t) => t.name === selectedTab);
const currentTabDisplayName = currentTab?.displayName || 'Settings';
const usersHeaderTitle = usersSubpageState ? `Settings / ${usersSubpageState.title}` : null;
const selectedAuthMethod = values.security?.AUTH_METHOD;
const usersAuthMode = typeof selectedAuthMethod === 'string' ? selectedAuthMethod : authMode;
const currentTabContent = currentTab
? (selectedTab === 'users' ? (
<UsersPanel
authMode={usersAuthMode}
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
onShowToast={onShowToast}
onSubpageStateChange={setUsersSubpageState}
/>
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
overrideSummary={tabOverrideSummaries[currentTab.name]}
/>
))
: null;
// Loading state
if (isLoading) {
@@ -301,27 +381,12 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
// Detail view
<>
<SettingsHeader
title={currentTabDisplayName}
title={selectedTab === 'users' && usersHeaderTitle ? usersHeaderTitle : currentTabDisplayName}
showBack
onBack={handleBack}
onBack={selectedTab === 'users' && usersSubpageState ? usersSubpageState.onBack : handleBack}
onClose={handleClose}
/>
{currentTab && (
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
)}
{currentTabContent}
</>
)}
</div>
@@ -350,7 +415,12 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
aria-modal="true"
aria-label="Settings"
>
<SettingsHeader title="Settings" onClose={handleClose} />
<SettingsHeader
title={selectedTab === 'users' && usersHeaderTitle ? usersHeaderTitle : 'Settings'}
showBack={selectedTab === 'users' && !!usersSubpageState}
onBack={selectedTab === 'users' && usersSubpageState ? usersSubpageState.onBack : undefined}
onClose={handleClose}
/>
<div className="flex flex-1 min-h-0">
<SettingsSidebar
@@ -361,22 +431,7 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
mode="sidebar"
/>
{currentTab ? (
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
) : (
{currentTabContent ?? (
<div className="flex-1 flex items-center justify-center text-sm opacity-60">
Select a category to configure
</div>
@@ -1,212 +1,182 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect } from 'react';
import { AdminUser } from '../../services/api';
import { ActionResult, SettingsTab } from '../../types/settings';
import {
AdminUser,
BookloreOption,
DownloadDefaults,
getAdminUsers,
getAdminUser,
getBookloreOptions,
getDownloadDefaults,
createAdminUser,
updateAdminUser,
deleteAdminUser,
} from '../../services/api';
canCreateLocalUsersForAuthMode,
UserListView,
UserOverridesView,
useUserForm,
useUserMutations,
useUsersFetch,
useUsersPanelState,
} from './users';
import { SettingsContent } from './SettingsContent';
import { SettingsSubpage } from './shared';
interface UsersPanelProps {
authMode: string;
tab: SettingsTab;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
onSave: () => Promise<void>;
onAction: (key: string) => Promise<ActionResult>;
isSaving: boolean;
hasChanges: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSubpageStateChange?: (state: { title: string; onBack: () => void } | null) => void;
}
const inputClasses =
'w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500 transition-colors';
export const UsersPanel = ({
authMode,
tab,
values,
onChange,
onSave,
onAction,
isSaving,
hasChanges,
onShowToast,
onSubpageStateChange,
}: UsersPanelProps) => {
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
const disabledInputClasses =
'w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-50 cursor-not-allowed';
const {
users,
loading,
loadError,
fetchUsers,
fetchUserEditContext,
} = useUsersFetch({ onShowToast });
interface PerUserSettings {
destination?: string;
booklore_library_id?: string;
booklore_path_id?: string;
email_recipients?: Array<{ nickname: string; email: string }>;
}
const {
createForm,
setCreateForm,
resetCreateForm,
editingUser,
setEditingUser,
editPassword,
setEditPassword,
editPasswordConfirm,
setEditPasswordConfirm,
downloadDefaults,
deliveryPreferences,
isUserOverridable,
userSettings,
setUserSettings,
beginEditing,
applyUserEditContext,
resetEditContext,
clearEditState,
userOverridableSettings,
} = useUserForm();
export const UsersPanel = ({ onShowToast }: UsersPanelProps) => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', display_name: '', role: 'user' });
const [creating, setCreating] = useState(false);
const {
creating,
saving,
deletingUserId,
syncingCwa,
createUser,
saveEditedUser,
deleteUser,
syncCwaUsers,
} = useUserMutations({
onShowToast,
fetchUsers,
createForm,
resetCreateForm,
editingUser,
editPassword,
editPasswordConfirm,
userSettings,
userOverridableSettings,
deliveryPreferences,
onEditSaveSuccess: clearEditState,
});
// Edit view state
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
const [bookloreLibraries, setBookloreLibraries] = useState<BookloreOption[]>([]);
const [booklorePaths, setBooklorePaths] = useState<BookloreOption[]>([]);
const fetchUsers = useCallback(async () => {
const startEditing = async (user: AdminUser) => {
beginEditing(user);
try {
setLoading(true);
setLoadError(null);
const data = await getAdminUsers();
setUsers(data);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load users';
setLoadError(msg);
onShowToast?.(msg, 'error');
} finally {
setLoading(false);
}
}, [onShowToast]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const startEditing = useCallback(async (user: AdminUser) => {
setEditingUser({ ...user });
setEditPassword('');
setEditPasswordConfirm('');
// Fetch full user data (with settings) and download defaults in parallel
try {
const [fullUser, defaults] = await Promise.all([
getAdminUser(user.id),
getDownloadDefaults(),
]);
setDownloadDefaults(defaults);
const settings = (fullUser.settings || {}) as PerUserSettings;
setUserSettings(settings);
// Fetch BookLore options if in booklore mode
if (defaults.BOOKS_OUTPUT_MODE === 'booklore') {
try {
const blOptions = await getBookloreOptions();
setBookloreLibraries(blOptions.libraries || []);
setBooklorePaths(blOptions.paths || []);
} catch {
setBookloreLibraries([]);
setBooklorePaths([]);
}
}
// Set override toggles based on which settings exist
setOverrides({
destination: !!settings.destination,
booklore_library_id: !!settings.booklore_library_id,
booklore_path_id: !!settings.booklore_path_id,
email_recipients: !!settings.email_recipients?.length,
});
const context = await fetchUserEditContext(user.id);
applyUserEditContext(context);
} catch {
setDownloadDefaults(null);
setUserSettings({});
setOverrides({});
}
}, []);
const handleDelete = async (userId: number) => {
try {
await deleteAdminUser(userId);
setConfirmDelete(null);
onShowToast?.('User deleted', 'success');
fetchUsers();
} catch {
onShowToast?.('Failed to delete user', 'error');
resetEditContext();
}
};
const handleSaveEdit = async () => {
if (!editingUser) return;
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
// Validate password if provided
if (editPassword) {
if (editPassword.length < 4) {
onShowToast?.('Password must be at least 4 characters', 'error');
return;
}
if (editPassword !== editPasswordConfirm) {
onShowToast?.('Passwords do not match', 'error');
return;
}
}
const handleBackToList = () => {
clearEditState();
backToList();
};
// Build settings payload: include overridden values, null out cleared overrides
const settingsPayload: Record<string, unknown> = {};
if (overrides.destination) {
settingsPayload.destination = userSettings.destination || '';
} else {
settingsPayload.destination = null;
}
if (overrides.booklore_library_id) {
settingsPayload.booklore_library_id = userSettings.booklore_library_id || '';
} else {
settingsPayload.booklore_library_id = null;
}
if (overrides.booklore_path_id) {
settingsPayload.booklore_path_id = userSettings.booklore_path_id || '';
} else {
settingsPayload.booklore_path_id = null;
}
if (overrides.email_recipients) {
settingsPayload.email_recipients = userSettings.email_recipients || [];
} else {
settingsPayload.email_recipients = null;
}
// Skip sending role when it's managed by OIDC group auth
const roleManaged = !!editingUser.oidc_subject && downloadDefaults?.OIDC_USE_ADMIN_GROUP === true;
try {
await updateAdminUser(editingUser.id, {
email: editingUser.email,
display_name: editingUser.display_name,
...(!roleManaged ? { role: editingUser.role } : {}),
...(editPassword ? { password: editPassword } : {}),
...(Object.keys(settingsPayload).length ? { settings: settingsPayload } : {}),
});
setEditingUser(null);
onShowToast?.('User updated', 'success');
fetchUsers();
} catch {
onShowToast?.('Failed to update user', 'error');
}
const handleCancelCreate = () => {
resetCreateForm();
backToList();
};
const handleCreate = async () => {
if (!createForm.username || !createForm.password) {
onShowToast?.('Username and password are required', 'error');
return;
}
if (createForm.password.length < 4) {
onShowToast?.('Password must be at least 4 characters', 'error');
return;
}
setCreating(true);
try {
const data = await createAdminUser(createForm as { username: string; password: string; email?: string; display_name?: string; role?: string });
setShowCreateForm(false);
setCreateForm({ username: '', email: '', password: '', display_name: '', role: 'user' });
onShowToast?.(`User ${data.username} created`, 'success');
fetchUsers();
} catch (err) {
onShowToast?.((err as Error).message || 'Failed to create user', 'error');
} finally {
setCreating(false);
const ok = await createUser();
if (ok) {
backToList();
}
};
const toggleOverride = (key: string, enabled: boolean) => {
setOverrides((prev) => ({ ...prev, [key]: enabled }));
if (!enabled) {
setUserSettings((prev) => {
const next = { ...prev };
(next as Record<string, unknown>)[key] = null;
return next;
const handleOpenOverrides = () => {
if (editingUser) {
openEditOverrides(editingUser.id);
}
};
const handleEdit = async (user: AdminUser) => {
openEdit(user.id);
await startEditing(user);
};
const handleSyncCwa = async () => {
await syncCwaUsers();
};
const handleBackToEdit = () => {
if (editingUser) {
openEdit(editingUser.id);
return;
}
backToList();
};
useEffect(() => {
if (!onSubpageStateChange) {
return undefined;
}
if (route.kind === 'edit-overrides') {
const username = editingUser && editingUser.id === route.userId
? editingUser.username
: 'User';
onSubpageStateChange({
title: `Users / User Preferences: ${username}`,
onBack: handleBackToEdit,
});
} else {
onSubpageStateChange(null);
}
return () => {
onSubpageStateChange(null);
};
}, [editingUser, handleBackToEdit, onSubpageStateChange, route]);
useEffect(() => {
if (route.kind === 'create' && !canCreateLocalUsers) {
backToList();
}
}, [backToList, canCreateLocalUsers, route.kind]);
const handleSave = async () => {
const ok = await saveEditedUser();
if (ok) {
backToList();
}
};
@@ -233,511 +203,75 @@ export const UsersPanel = ({ onShowToast }: UsersPanelProps) => {
);
}
// Edit view
if (editingUser) {
const outputMode = downloadDefaults?.BOOKS_OUTPUT_MODE || 'folder';
if (route.kind === 'edit-overrides') {
if (!editingUser || editingUser.id !== route.userId) {
return (
<div className="flex-1 flex items-center justify-center text-sm opacity-60 p-8">
Loading user details...
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setEditingUser(null)}
className="text-sm opacity-60 hover:opacity-100 transition-opacity"
>
&larr; Back
</button>
<h3 className="text-sm font-medium">Edit {editingUser.username}</h3>
</div>
<div className="space-y-5 max-w-lg">
{editingUser.oidc_subject && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
This user authenticates via SSO. Password is managed by the identity provider.
</div>
{downloadDefaults?.OIDC_USE_ADMIN_GROUP === true && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
{downloadDefaults?.OIDC_ADMIN_GROUP
? `Admin role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Admin group authorization is enabled but no group name is configured.'}
</div>
)}
</div>
)}
<div className="space-y-1.5">
<label className="text-sm font-medium">Display Name</label>
<input
type="text"
value={editingUser.display_name || ''}
onChange={(e) => setEditingUser({ ...editingUser, display_name: e.target.value || null })}
className={inputClasses}
placeholder="Display name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={editingUser.email || ''}
onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value || null })}
className={inputClasses}
placeholder="user@example.com"
/>
</div>
{/* Hide role dropdown for OIDC users when admin group auth is on (like password) */}
{!(!!editingUser.oidc_subject && downloadDefaults?.OIDC_USE_ADMIN_GROUP === true) && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Role</label>
<select
value={editingUser.role}
onChange={(e) => setEditingUser({ ...editingUser, role: e.target.value })}
className={inputClasses}
>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
)}
{/* Password section */}
{!editingUser.oidc_subject && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-3">Change Password</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">New Password</label>
<input
type="password"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className={inputClasses}
placeholder="Leave empty to keep current"
/>
</div>
{editPassword && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Confirm Password</label>
<input
type="password"
value={editPasswordConfirm}
onChange={(e) => setEditPasswordConfirm(e.target.value)}
className={inputClasses}
placeholder="Confirm new password"
/>
</div>
)}
</>
)}
{/* Per-user download settings overrides */}
{downloadDefaults && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-1">Download Settings Overrides</p>
<p className="text-xs opacity-40 mb-3">Override global defaults for this user.</p>
</div>
{/* Destination override (shown for folder mode) */}
{(outputMode === 'folder' || outputMode === 'booklore') && (
<OverrideField
label="Destination Folder"
enabled={overrides.destination || false}
onToggle={(v) => toggleOverride('destination', v)}
globalValue={downloadDefaults.DESTINATION || '/books'}
>
<input
type="text"
value={userSettings.destination || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, destination: e.target.value }))}
className={overrides.destination ? inputClasses : disabledInputClasses}
disabled={!overrides.destination}
placeholder={downloadDefaults.DESTINATION || '/books'}
/>
</OverrideField>
)}
{/* BookLore overrides */}
{outputMode === 'booklore' && (
<>
<OverrideField
label="BookLore Library"
enabled={overrides.booklore_library_id || false}
onToggle={(v) => toggleOverride('booklore_library_id', v)}
globalValue={
bookloreLibraries.find((l) => l.value === downloadDefaults.BOOKLORE_LIBRARY_ID)?.label
|| downloadDefaults.BOOKLORE_LIBRARY_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_library_id || ''}
onChange={(e) => {
setUserSettings((s) => ({ ...s, booklore_library_id: e.target.value, booklore_path_id: '' }));
// Reset path override when library changes
if (overrides.booklore_path_id) {
setOverrides((o) => ({ ...o, booklore_path_id: true }));
}
}}
className={overrides.booklore_library_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_library_id}
>
<option value="">Select library...</option>
{bookloreLibraries.map((lib) => (
<option key={lib.value} value={lib.value}>{lib.label}</option>
))}
</select>
</OverrideField>
<OverrideField
label="BookLore Path"
enabled={overrides.booklore_path_id || false}
onToggle={(v) => toggleOverride('booklore_path_id', v)}
globalValue={
booklorePaths.find((p) => p.value === downloadDefaults.BOOKLORE_PATH_ID)?.label
|| downloadDefaults.BOOKLORE_PATH_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_path_id || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, booklore_path_id: e.target.value }))}
className={overrides.booklore_path_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_path_id}
>
<option value="">Select path...</option>
{booklorePaths
.filter((p) => {
const selectedLib = userSettings.booklore_library_id || downloadDefaults.BOOKLORE_LIBRARY_ID;
return !p.childOf || p.childOf === selectedLib;
})
.map((path) => (
<option key={path.value} value={path.value}>{path.label}</option>
))}
</select>
</OverrideField>
</>
)}
{/* Email recipients override */}
{outputMode === 'email' && (
<OverrideField
label="Email Recipients"
enabled={overrides.email_recipients || false}
onToggle={(v) => toggleOverride('email_recipients', v)}
globalValue={
downloadDefaults.EMAIL_RECIPIENTS?.length
? downloadDefaults.EMAIL_RECIPIENTS.map((r) => r.nickname || r.email).join(', ')
: 'None configured'
}
>
{overrides.email_recipients && (
<EmailRecipientsEditor
recipients={userSettings.email_recipients || []}
onChange={(r) => setUserSettings((s) => ({ ...s, email_recipients: r }))}
/>
)}
</OverrideField>
)}
</>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleSaveEdit}
className="px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
>
Save Changes
</button>
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2.5 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
<UserOverridesView
onSave={handleSave}
saving={saving}
onBack={handleBackToEdit}
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
/>
);
}
// List view
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-xs opacity-60">
Users are created automatically via OIDC login, or manually below.
</p>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="px-3 py-1.5 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors shrink-0"
>
{showCreateForm ? 'Cancel' : 'Create User'}
</button>
<SettingsSubpage>
<div>
<UserListView
authMode={authMode}
users={users}
onCreate={openCreate}
showCreateForm={route.kind === 'create'}
createForm={createForm}
onCreateFormChange={setCreateForm}
creating={creating}
isFirstUser={users.length === 0}
onCreateSubmit={handleCreate}
onCancelCreate={handleCancelCreate}
showEditForm={route.kind === 'edit'}
activeEditUserId={route.kind === 'edit' ? route.userId : null}
editingUser={route.kind === 'edit' ? editingUser : null}
onEditingUserChange={setEditingUser}
onEditSave={handleSave}
saving={saving}
onCancelEdit={handleBackToList}
editPassword={editPassword}
onEditPasswordChange={setEditPassword}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={setEditPasswordConfirm}
downloadDefaults={downloadDefaults}
onOpenOverrides={handleOpenOverrides}
onEdit={handleEdit}
onDelete={deleteUser}
deletingUserId={deletingUserId}
onSyncCwa={handleSyncCwa}
syncingCwa={syncingCwa}
/>
<div className="pt-5 mt-4 border-t border-black/10 dark:border-white/10">
<SettingsContent
tab={tab}
values={values}
onChange={onChange}
onSave={onSave}
onAction={onAction}
isSaving={isSaving}
hasChanges={hasChanges}
embedded
/>
</div>
</div>
{showCreateForm && (
<div className="mb-4 p-4 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] space-y-3">
{users.length === 0 && (
<p className="text-xs opacity-60 pb-1">
This will be the first account and will be created as admin.
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Username <span className="text-red-500">*</span></label>
<input
type="text"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
className={inputClasses}
placeholder="username"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Display Name</label>
<input
type="text"
value={createForm.display_name}
onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })}
className={inputClasses}
placeholder="Display Name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
className={inputClasses}
placeholder="user@example.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Password <span className="text-red-500">*</span></label>
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
className={inputClasses}
placeholder="Min 4 characters"
/>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={createForm.role}
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value })}
className="px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm transition-colors"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onClick={handleCreate}
disabled={creating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
)}
{users.length === 0 ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm opacity-50">No users yet.</p>
<p className="text-xs opacity-40">
Create a local admin account before enabling OIDC to avoid getting locked out.
</p>
</div>
) : (
<div className="space-y-2">
{users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-3 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] transition-colors"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-400' : 'bg-zinc-500/20'}`}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{user.display_name || user.username}
</span>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
)}
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium
${user.oidc_subject
? 'bg-sky-500/15 text-sky-400'
: 'bg-zinc-500/15 opacity-70'}`}
>
{user.oidc_subject ? 'OIDC' : 'Password'}
</span>
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-xs px-2 py-0.5 rounded font-medium
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{user.role}
</span>
<button
onClick={() => startEditing(user)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Edit
</button>
{confirmDelete === user.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDelete(user.id)}
className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(user.id)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)] text-red-400
hover:bg-red-600 hover:text-white hover:border-red-600 transition-colors"
>
Delete
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface OverrideFieldProps {
label: string;
enabled: boolean;
onToggle: (enabled: boolean) => void;
globalValue: string;
children: React.ReactNode;
}
const OverrideField = ({ label, enabled, onToggle, globalValue, children }: OverrideFieldProps) => (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{label}</label>
<button
type="button"
onClick={() => onToggle(!enabled)}
className={`text-[10px] px-2 py-0.5 rounded font-medium transition-colors
${enabled
? 'bg-sky-500/15 text-sky-400 hover:bg-sky-500/25'
: 'bg-zinc-500/10 opacity-60 hover:opacity-80'}`}
>
{enabled ? 'Custom' : 'Global'}
</button>
</div>
{!enabled && (
<p className="text-xs opacity-40">Using global: {globalValue}</p>
)}
{children}
</div>
);
interface EmailRecipientsEditorProps {
recipients: Array<{ nickname: string; email: string }>;
onChange: (recipients: Array<{ nickname: string; email: string }>) => void;
}
const EmailRecipientsEditor = ({ recipients, onChange }: EmailRecipientsEditorProps) => {
const addRecipient = () => {
onChange([...recipients, { nickname: '', email: '' }]);
};
const removeRecipient = (index: number) => {
onChange(recipients.filter((_, i) => i !== index));
};
const updateRecipient = (index: number, field: 'nickname' | 'email', value: string) => {
const updated = [...recipients];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
return (
<div className="space-y-2">
{recipients.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={r.nickname}
onChange={(e) => updateRecipient(i, 'nickname', e.target.value)}
className={inputClasses}
placeholder="Nickname"
/>
<input
type="email"
value={r.email}
onChange={(e) => updateRecipient(i, 'email', e.target.value)}
className={inputClasses}
placeholder="email@example.com"
/>
<button
type="button"
onClick={() => removeRecipient(i)}
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-600 hover:text-white transition-colors shrink-0"
>
Remove
</button>
</div>
))}
<button
type="button"
onClick={addRecipient}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
+ Add Recipient
</button>
</div>
</SettingsSubpage>
);
};
@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { SettingsField } from '../../../types/settings';
import { Tooltip } from '../../shared/Tooltip';
import { EnvLockBadge } from './EnvLockBadge';
interface FieldWrapperProps {
@@ -8,6 +9,13 @@ interface FieldWrapperProps {
// Optional overrides for dynamic disabled state (from disabledWhen)
disabledOverride?: boolean;
disabledReasonOverride?: string;
headerRight?: ReactNode;
userOverrideCount?: number;
userOverrideDetails?: Array<{
userId: number;
username: string;
value: unknown;
}>;
}
// Badge shown when a field is disabled
@@ -60,11 +68,60 @@ const RestartRequiredBadge = () => (
</span>
);
const UserOverriddenBadge = ({
count,
details = [],
}: {
count: number;
details?: Array<{ userId: number; username: string; value: unknown }>;
}) => {
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '(empty)';
if (typeof value === 'string') return value || '(empty)';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const visibleDetails = details.slice(0, 10);
const extraCount = Math.max(details.length - visibleDetails.length, 0);
const content = (
<div className="space-y-1 max-w-xs">
{visibleDetails.map((entry) => (
<div key={entry.userId} className="text-[11px] leading-snug">
<span className="font-medium">{entry.username}</span>
<span className="opacity-70">: {formatValue(entry.value)}</span>
</div>
))}
{extraCount > 0 && (
<div className="text-[11px] opacity-70">and {extraCount} more...</div>
)}
</div>
);
return (
<Tooltip content={content} position="top">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-sky-500/15 text-sky-500 dark:text-sky-400 border border-sky-500/30"
>
User overridden{count > 1 ? ` (${count})` : ''}
</span>
</Tooltip>
);
};
export const FieldWrapper = ({
field,
children,
disabledOverride,
disabledReasonOverride,
headerRight,
userOverrideCount,
userOverrideDetails,
}: FieldWrapperProps) => {
// Action buttons, headings, and table fields handle their own layout
// Table fields have column headers, so they don't need a separate label
@@ -82,25 +139,34 @@ export const FieldWrapper = ({
const isFullyDimmed = isDisabled && !field.fromEnv;
return (
<div className={`space-y-1.5 ${isFullyDimmed ? 'opacity-60' : ''}`}>
<div className="flex items-center gap-2">
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
{Boolean(userOverrideCount) && (userOverrideCount || 0) > 0 && (
<UserOverriddenBadge
count={userOverrideCount || 0}
details={userOverrideDetails}
/>
)}
</div>
<div className="flex items-center gap-2 shrink-0">{headerRight}</div>
</div>
{children}
<div className={isFullyDimmed ? 'opacity-50' : ''}>{children}</div>
{field.description && (
<p className="text-xs opacity-60">{field.description}</p>
)}
{isDisabled && disabledReason && (
<p className="text-xs text-zinc-500 italic">{disabledReason}</p>
<p className="text-xs text-zinc-400 italic">{disabledReason}</p>
)}
</div>
);
@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
interface SettingsSubpageProps {
children: ReactNode;
}
export const SettingsSubpage = ({
children,
}: SettingsSubpageProps) => {
return (
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
);
};
@@ -1,2 +1,3 @@
export { EnvLockBadge } from './EnvLockBadge';
export { FieldWrapper } from './FieldWrapper';
export { SettingsSubpage } from './SettingsSubpage';
@@ -0,0 +1,29 @@
import { AdminUser } from '../../../services/api';
import {
AUTH_SOURCE_BADGE_CLASSES,
AUTH_SOURCE_LABEL,
} from './types';
interface UserAuthSourceBadgeProps {
user: AdminUser;
showInactive?: boolean;
}
export const UserAuthSourceBadge = ({ user, showInactive = true }: UserAuthSourceBadgeProps) => {
const authSource = user.auth_source;
const active = user.is_active !== false;
const badgeBase = 'inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none';
return (
<>
<span className={`${badgeBase} ${AUTH_SOURCE_BADGE_CLASSES[authSource]}`}>
{AUTH_SOURCE_LABEL[authSource]}
</span>
{showInactive && !active && (
<span className={`${badgeBase} bg-zinc-500/10 opacity-80`}>
Inactive
</span>
)}
</>
);
};
@@ -0,0 +1,276 @@
import { ReactNode } from 'react';
import { AdminUser, DownloadDefaults } from '../../../services/api';
import { PasswordFieldConfig, SelectFieldConfig, SelectOption, TextFieldConfig } from '../../../types/settings';
import { PasswordField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { CreateUserFormState } from './types';
const UserCardShell = ({ title, children }: { title: string; children: ReactNode }) => (
<div className="space-y-5 p-4 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)]">
<h3 className="text-sm font-medium">{title}</h3>
{children}
</div>
);
const CREATE_ROLE_OPTIONS: SelectOption[] = [
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
];
const EDIT_ROLE_OPTIONS: SelectOption[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
];
const createTextField = (
key: string,
label: string,
value: string,
placeholder: string,
required = false,
): TextFieldConfig => ({
type: 'TextField',
key,
label,
value,
placeholder,
required,
});
const createPasswordField = (
key: string,
label: string,
value: string,
placeholder: string,
required = false,
): PasswordFieldConfig => ({
type: 'PasswordField',
key,
label,
value,
placeholder,
required,
});
const createRoleField = (value: string, options: SelectOption[]): SelectFieldConfig => ({
type: 'SelectField',
key: 'role',
label: 'Role',
value,
options,
});
const renderTextField = (
field: TextFieldConfig,
value: string,
onChange: (value: string) => void,
disabled = false,
disabledReason?: string,
) => (
<FieldWrapper field={field} disabledOverride={disabled} disabledReasonOverride={disabledReason}>
<TextField field={field} value={value} onChange={onChange} disabled={disabled} />
</FieldWrapper>
);
const renderSelectField = (
field: SelectFieldConfig,
value: string,
onChange: (value: string) => void,
disabled = false,
disabledReason?: string,
) => (
<FieldWrapper field={field} disabledOverride={disabled} disabledReasonOverride={disabledReason}>
<SelectField field={field} value={value} onChange={onChange} disabled={disabled} />
</FieldWrapper>
);
const renderPasswordField = (
field: PasswordFieldConfig,
value: string,
onChange: (value: string) => void,
) => (
<FieldWrapper field={field}>
<PasswordField field={field} value={value} onChange={onChange} />
</FieldWrapper>
);
interface UserCreateCardProps {
form: CreateUserFormState;
onChange: (form: CreateUserFormState) => void;
creating: boolean;
isFirstUser: boolean;
onSubmit: () => void;
onCancel: () => void;
}
export const UserCreateCard = ({
form,
onChange,
creating,
isFirstUser,
onSubmit,
onCancel,
}: UserCreateCardProps) => {
const usernameField = createTextField('username', 'Username', form.username, 'username', true);
const displayNameField = createTextField('display_name', 'Display Name', form.display_name, 'Display name');
const emailField = createTextField('email', 'Email', form.email, 'user@example.com');
const passwordField = createPasswordField('password', 'Password', form.password, 'Min 4 characters', true);
const roleField = createRoleField(form.role, CREATE_ROLE_OPTIONS);
return (
<UserCardShell title="Create Local User">
{isFirstUser && (
<p className="text-xs text-zinc-500">
This will be the first account and will be created as admin.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{renderTextField(usernameField, form.username, (value) => onChange({ ...form, username: value }))}
{renderTextField(displayNameField, form.display_name, (value) => onChange({ ...form, display_name: value }))}
{renderTextField(emailField, form.email, (value) => onChange({ ...form, email: value }))}
{renderPasswordField(passwordField, form.password, (value) => onChange({ ...form, password: value }))}
</div>
{renderSelectField(roleField, form.role, (value) => onChange({ ...form, role: value }))}
<div className="flex items-center gap-2 pt-1">
<button
onClick={onSubmit}
disabled={creating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? 'Creating...' : 'Create Local User'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
</UserCardShell>
);
};
interface UserEditFieldsProps {
user: AdminUser;
onUserChange: (user: AdminUser) => void;
onSave: () => void;
saving: boolean;
onCancel: () => void;
editPassword: string;
onEditPasswordChange: (value: string) => void;
editPasswordConfirm: string;
onEditPasswordConfirmChange: (value: string) => void;
downloadDefaults: DownloadDefaults | null;
onEditOverrides?: () => void;
}
export const UserEditFields = ({
user,
onUserChange,
onSave,
saving,
onCancel,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
onEditPasswordConfirmChange,
downloadDefaults,
onEditOverrides,
}: UserEditFieldsProps) => {
const capabilities = user.edit_capabilities;
const { authSource, canSetPassword, canEditRole, canEditEmail, canEditDisplayName } = capabilities;
const displayNameField = createTextField('display_name', 'Display Name', user.display_name || '', 'Display name');
const emailField = createTextField('email', 'Email', user.email || '', 'user@example.com');
const roleField = createRoleField(user.role, EDIT_ROLE_OPTIONS);
const newPasswordField = createPasswordField('new_password', 'New Password', editPassword, 'Leave empty to keep current');
const confirmPasswordField = createPasswordField('confirm_password', 'Confirm Password', editPasswordConfirm, 'Confirm new password', true);
const displayNameDisabledReason = !canEditDisplayName
? 'Display name is managed by the identity provider.'
: undefined;
const emailDisabledReason = !canEditEmail
? (authSource === 'cwa'
? 'Email is synced from Calibre-Web.'
: 'Email is managed by your identity provider.')
: undefined;
const roleDisabledReason = !canEditRole
? (authSource === 'oidc'
? (downloadDefaults?.OIDC_ADMIN_GROUP
? `Role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Role is managed by OIDC group authorization.')
: 'Role is managed by the external authentication source.')
: undefined;
return (
<>
{renderTextField(
displayNameField,
user.display_name || '',
(value) => onUserChange({ ...user, display_name: value || null }),
!canEditDisplayName,
displayNameDisabledReason,
)}
{renderTextField(
emailField,
user.email || '',
(value) => onUserChange({ ...user, email: value || null }),
!canEditEmail,
emailDisabledReason,
)}
{renderSelectField(
roleField,
user.role,
(value) => onUserChange({ ...user, role: value }),
!canEditRole,
roleDisabledReason,
)}
{canSetPassword && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-3">Change Password</p>
</div>
{renderPasswordField(newPasswordField, editPassword, onEditPasswordChange)}
{editPassword && renderPasswordField(confirmPasswordField, editPasswordConfirm, onEditPasswordConfirmChange)}
</>
)}
<div className="flex flex-wrap gap-2 pt-2">
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
{onEditOverrides && (
<button
onClick={onEditOverrides}
className="ml-auto px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
User Preferences
</button>
)}
</div>
</>
);
};
@@ -0,0 +1,284 @@
import { useState } from 'react';
import { AdminUser, DownloadDefaults } from '../../../services/api';
import {
canCreateLocalUsersForAuthMode,
CreateUserFormState,
getUsersHeadingDescriptionForAuthMode,
} from './types';
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
import { UserCreateCard, UserEditFields } from './UserCard';
import { HeadingField } from '../fields';
import { HeadingFieldConfig } from '../../../types/settings';
interface UserListViewProps {
authMode: string;
users: AdminUser[];
onCreate: () => void;
showCreateForm: boolean;
createForm: CreateUserFormState;
onCreateFormChange: (form: CreateUserFormState) => void;
creating: boolean;
isFirstUser: boolean;
onCreateSubmit: () => void;
onCancelCreate: () => void;
showEditForm: boolean;
activeEditUserId: number | null;
editingUser: AdminUser | null;
onEditingUserChange: (user: AdminUser) => void;
onEditSave: () => void;
saving: boolean;
onCancelEdit: () => void;
editPassword: string;
onEditPasswordChange: (value: string) => void;
editPasswordConfirm: string;
onEditPasswordConfirmChange: (value: string) => void;
downloadDefaults: DownloadDefaults | null;
onOpenOverrides: () => void;
onEdit: (user: AdminUser) => void;
onDelete: (userId: number) => Promise<boolean>;
deletingUserId: number | null;
onSyncCwa: () => Promise<void> | void;
syncingCwa: boolean;
}
export const UserListView = ({
authMode,
users,
onCreate,
showCreateForm,
createForm,
onCreateFormChange,
creating,
isFirstUser,
onCreateSubmit,
onCancelCreate,
showEditForm,
activeEditUserId,
editingUser,
onEditingUserChange,
onEditSave,
saving,
onCancelEdit,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
onEditPasswordConfirmChange,
downloadDefaults,
onOpenOverrides,
onEdit,
onDelete,
deletingUserId,
onSyncCwa,
syncingCwa,
}: UserListViewProps) => {
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
const isCwaMode = String(authMode || 'none').toLowerCase() === 'cwa';
const usersHeading: HeadingFieldConfig = {
key: 'users_heading',
type: 'HeadingField',
title: 'Users',
description: getUsersHeadingDescriptionForAuthMode(authMode),
};
const handleDelete = async (userId: number) => {
const ok = await onDelete(userId);
if (ok) {
setConfirmDelete(null);
}
};
return (
<div className="space-y-4">
<div>
<HeadingField field={usersHeading} />
</div>
{users.length === 0 ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm opacity-50">No users yet.</p>
<p className="text-xs opacity-40">
Create a local admin account before enabling OIDC to avoid getting locked out.
</p>
</div>
) : (
<div className="space-y-2">
{users.map((user) => {
const active = user.is_active !== false;
const isEditingRow = showEditForm && activeEditUserId === user.id;
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
return (
<div
key={user.id}
className={`rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] transition-colors ${active ? '' : 'opacity-60'}`}
>
<div className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/20'}`}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{user.display_name || user.username}
</span>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
)}
<UserAuthSourceBadge user={user} />
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
{!active && (
<div className="text-[11px] opacity-60 truncate">
Inactive for current authentication mode
</div>
)}
</div>
</div>
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
<span
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{roleLabel}
</span>
{!isEditingRow && (
<>
<button
onClick={() => onEdit(user)}
className="p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
aria-label={`Edit ${user.username}`}
title="Edit user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-[18px] h-[18px]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
</button>
{confirmDelete === user.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDelete(user.id)}
disabled={deletingUserId === user.id}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{deletingUserId === user.id ? 'Deleting...' : 'Confirm'}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(user.id)}
className="p-2 rounded-full hover-action transition-colors text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
aria-label={`Delete ${user.username}`}
title="Delete user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-[18px] h-[18px]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
)}
</>
)}
</div>
</div>
{isEditingRow && (
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<UserEditFields
user={editingUser}
onUserChange={onEditingUserChange}
onSave={onEditSave}
saving={saving}
onCancel={onCancelEdit}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
downloadDefaults={downloadDefaults}
onEditOverrides={onOpenOverrides}
/>
) : (
<div className="text-sm opacity-60">Loading user details...</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{canCreateLocalUsers && (
<div>
{showCreateForm ? (
<UserCreateCard
form={createForm}
onChange={onCreateFormChange}
creating={creating}
isFirstUser={isFirstUser}
onSubmit={onCreateSubmit}
onCancel={onCancelCreate}
/>
) : (
<button
onClick={onCreate}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
>
Create Local User
</button>
)}
</div>
)}
{!canCreateLocalUsers && isCwaMode && (
<div>
<button
onClick={onSyncCwa}
disabled={syncingCwa}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{syncingCwa ? 'Syncing with CWA...' : 'Sync with CWA'}
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,384 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import {
HeadingFieldConfig,
SelectFieldConfig,
SettingsField,
TextFieldConfig,
} from '../../../types/settings';
import { HeadingField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { PerUserSettings } from './types';
interface UserOverridesSectionProps {
deliveryPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
userSettings: PerUserSettings;
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
}
const modeOptions = [
{ value: 'folder', label: 'Folder' },
{ value: 'email', label: 'Email (SMTP)' },
{ value: 'booklore', label: 'BookLore (API)' },
];
const fallbackOutputModeField: SelectFieldConfig = {
type: 'SelectField',
key: 'BOOKS_OUTPUT_MODE',
label: 'Output Mode',
description: 'Choose where completed book files are sent.',
value: 'folder',
options: modeOptions,
};
const fallbackDestinationField: TextFieldConfig = {
type: 'TextField',
key: 'DESTINATION',
label: 'Destination',
description: 'Directory where downloaded files are saved.',
value: '',
placeholder: '/books',
};
const fallbackDestinationAudiobookField: TextFieldConfig = {
type: 'TextField',
key: 'DESTINATION_AUDIOBOOK',
label: 'Destination',
description: 'Directory where downloaded audiobook files are saved. Use {User} for per-user folders and leave empty to use the books destination.',
value: '',
placeholder: '/audiobooks',
};
const fallbackBookloreLibraryField: SelectFieldConfig = {
type: 'SelectField',
key: 'BOOKLORE_LIBRARY_ID',
label: 'Library',
description: 'BookLore library to upload into.',
value: '',
options: [],
};
const fallbackBooklorePathField: SelectFieldConfig = {
type: 'SelectField',
key: 'BOOKLORE_PATH_ID',
label: 'Path',
description: 'BookLore library path for uploads.',
value: '',
options: [],
filterByField: 'BOOKLORE_LIBRARY_ID',
};
const fallbackEmailRecipientField: TextFieldConfig = {
type: 'TextField',
key: 'EMAIL_RECIPIENT',
label: 'Email Recipient',
description: 'Email address used for this user in Email output mode.',
value: '',
placeholder: 'reader@example.com',
};
type DeliverySettingKey = keyof PerUserSettings;
function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'booklore' || mode === 'email') {
return mode;
}
return 'folder';
}
function toStringValue(value: unknown): string {
if (value === undefined || value === null) return '';
return String(value);
}
function getFieldByKey<T extends SettingsField>(
fields: SettingsField[] | undefined,
key: string,
fallback: T
): T {
const found = fields?.find((field) => field.key === key);
if (!found) {
return fallback;
}
return found as T;
}
interface ResetOverrideButtonProps {
disabled?: boolean;
label?: string;
onClick: () => void;
}
const ResetOverrideButton = ({ disabled = false, label = 'Reset', onClick }: ResetOverrideButtonProps) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{label}
</button>
);
const deliveryHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_heading',
title: 'Delivery Preferences',
description: 'Editing values here creates per-user settings. Use Reset to inherit global values.',
};
const booksHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_books_heading',
title: 'Books',
description: 'Output mode and destination behavior for ebooks, comics, and magazines.',
};
const audiobooksHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_audiobooks_heading',
title: 'Audiobooks',
description: 'Audiobooks always use folder output. Set a user-specific destination or leave empty to inherit books.',
};
const BOOK_PREFERENCE_KEYS: DeliverySettingKey[] = [
'BOOKS_OUTPUT_MODE',
'DESTINATION',
'BOOKLORE_LIBRARY_ID',
'BOOKLORE_PATH_ID',
'EMAIL_RECIPIENT',
];
const AUDIOBOOK_PREFERENCE_KEYS: DeliverySettingKey[] = ['DESTINATION_AUDIOBOOK'];
export const UserOverridesSection = ({
deliveryPreferences,
isUserOverridable,
userSettings,
setUserSettings,
}: UserOverridesSectionProps) => {
const fields = deliveryPreferences?.fields ?? [];
const globalValues = deliveryPreferences?.globalValues ?? {};
const preferenceKeys = deliveryPreferences?.keys ?? [];
const outputModeField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKS_OUTPUT_MODE', fallbackOutputModeField);
const destinationField = getFieldByKey<TextFieldConfig>(fields, 'DESTINATION', fallbackDestinationField);
const destinationAudiobookField = getFieldByKey<TextFieldConfig>(fields, 'DESTINATION_AUDIOBOOK', fallbackDestinationAudiobookField);
const bookloreLibraryField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_LIBRARY_ID', fallbackBookloreLibraryField);
const booklorePathField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_PATH_ID', fallbackBooklorePathField);
const emailRecipientField = getFieldByKey<TextFieldConfig>(fields, 'EMAIL_RECIPIENT', fallbackEmailRecipientField);
const isOverridden = (key: DeliverySettingKey): boolean =>
Object.prototype.hasOwnProperty.call(userSettings, key) &&
userSettings[key] !== null &&
userSettings[key] !== undefined;
const resetKeys = (keys: DeliverySettingKey[]) => {
setUserSettings((prev) => {
const next = { ...prev };
keys.forEach((key) => {
delete next[key];
});
return next;
});
};
const readValue = (key: DeliverySettingKey, fallback = ''): string => {
if (isOverridden(key)) {
return toStringValue(userSettings[key]);
}
if (key in globalValues) {
return toStringValue(globalValues[key]);
}
return fallback;
};
const outputModeValue = readValue('BOOKS_OUTPUT_MODE', 'folder');
const effectiveOutputMode = normalizeMode(outputModeValue);
const destinationValue = readValue('DESTINATION');
const destinationAudiobookValue = readValue('DESTINATION_AUDIOBOOK');
const libraryValue = readValue('BOOKLORE_LIBRARY_ID');
const pathValue = readValue('BOOKLORE_PATH_ID');
const emailRecipientValue = readValue('EMAIL_RECIPIENT');
const availableBookPreferenceKeys = BOOK_PREFERENCE_KEYS.filter((key) => preferenceKeys.includes(String(key)));
const availableAudiobookPreferenceKeys = AUDIOBOOK_PREFERENCE_KEYS.filter((key) => preferenceKeys.includes(String(key)));
const hasBookDeliveryOverride = availableBookPreferenceKeys.some((key) => isOverridden(key));
const hasAudiobookDeliveryOverride = availableAudiobookPreferenceKeys.some((key) => isOverridden(key));
const canOverrideOutputMode = isUserOverridable('BOOKS_OUTPUT_MODE');
const canOverrideDestination = isUserOverridable('DESTINATION');
const canOverrideAudiobookDestination = isUserOverridable('DESTINATION_AUDIOBOOK');
const canOverrideBookloreLibrary = isUserOverridable('BOOKLORE_LIBRARY_ID');
const canOverrideBooklorePath = isUserOverridable('BOOKLORE_PATH_ID');
const canOverrideEmailRecipient = isUserOverridable('EMAIL_RECIPIENT');
if (!deliveryPreferences) {
return null;
}
return (
<div className="space-y-4">
<HeadingField field={deliveryHeading} />
<HeadingField field={booksHeading} />
{canOverrideOutputMode && (
<FieldWrapper
field={outputModeField}
headerRight={
hasBookDeliveryOverride ? (
<ResetOverrideButton
label="Reset all"
onClick={() => resetKeys(availableBookPreferenceKeys)}
/>
) : undefined
}
>
<SelectField
field={outputModeField}
value={outputModeValue}
onChange={(value) => {
setUserSettings((prev) => {
const next: PerUserSettings = { ...prev, BOOKS_OUTPUT_MODE: value };
if (value === 'folder') {
delete next.BOOKLORE_LIBRARY_ID;
delete next.BOOKLORE_PATH_ID;
delete next.EMAIL_RECIPIENT;
} else if (value === 'booklore') {
delete next.DESTINATION;
delete next.EMAIL_RECIPIENT;
} else if (value === 'email') {
delete next.DESTINATION;
delete next.BOOKLORE_LIBRARY_ID;
delete next.BOOKLORE_PATH_ID;
}
return next;
});
}}
disabled={Boolean(outputModeField.fromEnv)}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'folder' && canOverrideDestination && (
<FieldWrapper
field={destinationField}
headerRight={
isOverridden('DESTINATION') ? (
<ResetOverrideButton
disabled={Boolean(destinationField.fromEnv)}
onClick={() => resetKeys(['DESTINATION'])}
/>
) : undefined
}
>
<TextField
field={destinationField}
value={destinationValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DESTINATION: value }))}
disabled={Boolean(destinationField.fromEnv)}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'booklore' && canOverrideBookloreLibrary && (
<FieldWrapper
field={bookloreLibraryField}
headerRight={
isOverridden('BOOKLORE_LIBRARY_ID') ? (
<ResetOverrideButton
disabled={Boolean(bookloreLibraryField.fromEnv)}
onClick={() => resetKeys(['BOOKLORE_LIBRARY_ID'])}
/>
) : undefined
}
>
<SelectField
field={bookloreLibraryField}
value={libraryValue}
onChange={(value) => {
setUserSettings((prev) => ({
...prev,
BOOKLORE_LIBRARY_ID: value,
BOOKLORE_PATH_ID: '',
}));
}}
disabled={Boolean(bookloreLibraryField.fromEnv)}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'booklore' && canOverrideBooklorePath && (
<FieldWrapper
field={booklorePathField}
headerRight={
isOverridden('BOOKLORE_PATH_ID') ? (
<ResetOverrideButton
disabled={Boolean(booklorePathField.fromEnv)}
onClick={() => resetKeys(['BOOKLORE_PATH_ID'])}
/>
) : undefined
}
>
<SelectField
field={booklorePathField}
value={pathValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, BOOKLORE_PATH_ID: value }))}
disabled={Boolean(booklorePathField.fromEnv)}
filterValue={libraryValue || undefined}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'email' && canOverrideEmailRecipient && (
<FieldWrapper
field={emailRecipientField}
headerRight={
isOverridden('EMAIL_RECIPIENT') ? (
<ResetOverrideButton
disabled={Boolean(emailRecipientField.fromEnv)}
onClick={() => resetKeys(['EMAIL_RECIPIENT'])}
/>
) : undefined
}
>
<TextField
field={emailRecipientField}
value={emailRecipientValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, EMAIL_RECIPIENT: value }))}
disabled={Boolean(emailRecipientField.fromEnv)}
/>
</FieldWrapper>
)}
{canOverrideAudiobookDestination && (
<>
<HeadingField field={audiobooksHeading} />
<FieldWrapper
field={destinationAudiobookField}
headerRight={
hasAudiobookDeliveryOverride ? (
<ResetOverrideButton
disabled={Boolean(destinationAudiobookField.fromEnv)}
onClick={() => resetKeys(availableAudiobookPreferenceKeys)}
/>
) : undefined
}
>
<TextField
field={destinationAudiobookField}
value={destinationAudiobookValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DESTINATION_AUDIOBOOK: value }))}
disabled={Boolean(destinationAudiobookField.fromEnv)}
/>
</FieldWrapper>
</>
)}
</div>
);
};
@@ -0,0 +1,52 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import { PerUserSettings } from './types';
import { SettingsSubpage } from '../shared';
import { UserOverridesSection } from './UserOverridesSection';
interface UserOverridesViewProps {
onSave: () => void;
saving: boolean;
onBack: () => void;
deliveryPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
userSettings: PerUserSettings;
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
}
export const UserOverridesView = ({
onSave,
saving,
onBack,
deliveryPreferences,
isUserOverridable,
userSettings,
setUserSettings,
}: UserOverridesViewProps) => (
<SettingsSubpage>
<div className="space-y-5">
<UserOverridesSection
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
/>
<div className="flex gap-2 pt-2">
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onBack}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Back
</button>
</div>
</div>
</SettingsSubpage>
);
@@ -0,0 +1,10 @@
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
export { UserCreateCard, UserEditFields } from './UserCard';
export { UserListView } from './UserListView';
export { UserOverridesSection } from './UserOverridesSection';
export { UserOverridesView } from './UserOverridesView';
export { useUserForm } from './useUserForm';
export { useUserMutations } from './useUserMutations';
export { useUsersFetch } from './useUsersFetch';
export { useUsersPanelState } from './useUsersPanelState';
export { canCreateLocalUsersForAuthMode, getUsersHeadingDescriptionForAuthMode } from './types';
@@ -0,0 +1,72 @@
import { AdminUser } from '../../../services/api';
export interface PerUserSettings {
[key: string]: unknown;
BOOKS_OUTPUT_MODE?: string;
DESTINATION?: string;
DESTINATION_AUDIOBOOK?: string;
BOOKLORE_LIBRARY_ID?: string;
BOOKLORE_PATH_ID?: string;
EMAIL_RECIPIENT?: string;
}
export interface CreateUserFormState {
username: string;
email: string;
password: string;
display_name: string;
role: string;
}
export const INITIAL_CREATE_FORM: CreateUserFormState = {
username: '',
email: '',
password: '',
display_name: '',
role: 'user',
};
export type UsersPanelRoute =
| { kind: 'list' }
| { kind: 'create' }
| { kind: 'edit'; userId: number }
| { kind: 'edit-overrides'; userId: number };
export type AuthSource = AdminUser['auth_source'];
export const AUTH_SOURCE_LABEL: Record<AuthSource, string> = {
builtin: 'Local',
oidc: 'OIDC',
proxy: 'Proxy',
cwa: 'CWA',
};
export const AUTH_SOURCE_BADGE_CLASSES: Record<AuthSource, string> = {
builtin: 'bg-zinc-500/15 opacity-70',
oidc: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
proxy: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
cwa: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
};
export const canCreateLocalUsersForAuthMode = (authMode?: string): boolean => {
const normalized = String(authMode || 'none').toLowerCase();
return normalized === 'none' || normalized === 'builtin' || normalized === 'oidc';
};
export const getUsersHeadingDescriptionForAuthMode = (authMode?: string): string => {
const normalized = String(authMode || 'none').toLowerCase();
if (normalized === 'builtin') {
return 'Create and manage user accounts directly. Passwords are stored locally and users sign in with their username and password.';
}
if (normalized === 'oidc') {
return 'Users sign in through your identity provider. New accounts can be created automatically on first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll be linked by email on first sign-in.';
}
if (normalized === 'proxy') {
return 'Users are authenticated by your reverse proxy. Accounts are automatically created on first sign-in. If a local user with a matching username already exists, it will be linked instead.';
}
if (normalized === 'cwa') {
return 'User accounts are synced from your Calibre-Web database. Users are matched by email, and new accounts are created here when new CWA users are found.';
}
return 'Authentication is disabled. Anyone can access Shelfmark without signing in.';
};
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { AdminUser, DeliveryPreferencesResponse, DownloadDefaults } from '../../../services/api';
import { CreateUserFormState, INITIAL_CREATE_FORM, PerUserSettings } from './types';
import { UserEditContext } from './useUsersFetch';
export const useUserForm = () => {
const [createForm, setCreateForm] = useState<CreateUserFormState>({ ...INITIAL_CREATE_FORM });
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const resetCreateForm = () => setCreateForm({ ...INITIAL_CREATE_FORM });
const resetEditContext = () => {
setDownloadDefaults(null);
setDeliveryPreferences(null);
setUserSettings({});
setUserOverridableSettings(new Set());
};
const beginEditing = (user: AdminUser) => {
setEditingUser({ ...user });
setEditPassword('');
setEditPasswordConfirm('');
};
const applyUserEditContext = (context: UserEditContext) => {
setEditingUser({ ...context.user });
setDownloadDefaults(context.downloadDefaults);
setDeliveryPreferences(context.deliveryPreferences);
setUserSettings(context.userSettings);
setUserOverridableSettings(new Set(context.userOverridableSettings));
};
const clearEditState = () => {
setEditingUser(null);
setEditPassword('');
setEditPasswordConfirm('');
resetEditContext();
};
const isUserOverridable = (key: keyof PerUserSettings) => userOverridableSettings.has(String(key));
return {
createForm,
setCreateForm,
resetCreateForm,
editingUser,
setEditingUser,
beginEditing,
applyUserEditContext,
resetEditContext,
clearEditState,
editPassword,
setEditPassword,
editPasswordConfirm,
setEditPasswordConfirm,
downloadDefaults,
deliveryPreferences,
userSettings,
setUserSettings,
userOverridableSettings,
isUserOverridable,
};
};
@@ -0,0 +1,151 @@
import { useState } from 'react';
import {
AdminUser,
DeliveryPreferencesResponse,
createAdminUser,
deleteAdminUser,
syncAdminCwaUsers,
updateAdminUser,
} from '../../../services/api';
import { CreateUserFormState, PerUserSettings } from './types';
const MIN_PASSWORD_LENGTH = 4;
interface UseUserMutationsParams {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
fetchUsers: () => Promise<void>;
createForm: CreateUserFormState;
resetCreateForm: () => void;
editingUser: AdminUser | null;
editPassword: string;
editPasswordConfirm: string;
userSettings: PerUserSettings;
userOverridableSettings: Set<string>;
deliveryPreferences: DeliveryPreferencesResponse | null;
onEditSaveSuccess?: () => void;
}
const getPasswordError = (password: string, passwordConfirm: string) => {
if (!password) return null;
if (password.length < MIN_PASSWORD_LENGTH) return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
return password === passwordConfirm ? null : 'Passwords do not match';
};
const buildSettingsPayload = (userSettings: PerUserSettings, userOverridableSettings: Set<string>, deliveryPreferences: DeliveryPreferencesResponse | null) =>
(deliveryPreferences?.keys || [...userOverridableSettings]).reduce<Record<string, unknown>>((payload, key) => {
const typedKey = key as keyof PerUserSettings;
payload[key] = Object.prototype.hasOwnProperty.call(userSettings, typedKey) && userSettings[typedKey] !== null && userSettings[typedKey] !== undefined
? (userSettings[typedKey] ?? '')
: null;
return payload;
}, {});
export const useUserMutations = ({
onShowToast,
fetchUsers,
createForm,
resetCreateForm,
editingUser,
editPassword,
editPasswordConfirm,
userSettings,
userOverridableSettings,
deliveryPreferences,
onEditSaveSuccess,
}: UseUserMutationsParams) => {
const [creating, setCreating] = useState(false);
const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<number | null>(null);
const [syncingCwa, setSyncingCwa] = useState(false);
const fail = (message: string) => (onShowToast?.(message, 'error'), false);
const createUser = async () => {
if (!createForm.username || !createForm.password) return fail('Username and password are required');
if (createForm.password.length < MIN_PASSWORD_LENGTH) return fail(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
setCreating(true);
try {
const created = await createAdminUser({
username: createForm.username,
password: createForm.password,
email: createForm.email || undefined,
display_name: createForm.display_name || undefined,
role: createForm.role || undefined,
});
resetCreateForm();
onShowToast?.(`Local user ${created.username} created`, 'success');
await fetchUsers();
return true;
} catch (err) {
return fail(err instanceof Error ? err.message : 'Failed to create user');
} finally {
setCreating(false);
}
};
const saveEditedUser = async () => {
if (!editingUser) return false;
const passwordError = getPasswordError(editPassword, editPasswordConfirm);
if (passwordError) return fail(passwordError);
const caps = editingUser.edit_capabilities;
const settingsPayload = buildSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences);
setSaving(true);
try {
await updateAdminUser(editingUser.id, {
...(caps.canEditEmail ? { email: editingUser.email } : {}),
...(caps.canEditDisplayName ? { display_name: editingUser.display_name } : {}),
...(caps.canEditRole ? { role: editingUser.role } : {}),
...(caps.canSetPassword && editPassword ? { password: editPassword } : {}),
...(Object.keys(settingsPayload).length > 0 ? { settings: settingsPayload } : {}),
});
onEditSaveSuccess?.();
onShowToast?.('User updated', 'success');
await fetchUsers();
return true;
} catch {
return fail('Failed to update user');
} finally {
setSaving(false);
}
};
const deleteUser = async (userId: number) => {
setDeletingUserId(userId);
try {
await deleteAdminUser(userId);
onShowToast?.('User deleted', 'success');
await fetchUsers();
return true;
} catch {
return fail('Failed to delete user');
} finally {
setDeletingUserId(null);
}
};
const syncCwaUsers = async () => {
setSyncingCwa(true);
try {
const result = await syncAdminCwaUsers();
onShowToast?.(result.message || 'Users synced from CWA', 'success');
await fetchUsers();
return true;
} catch (err) {
return fail(err instanceof Error ? err.message : 'Failed to sync users from CWA');
} finally {
setSyncingCwa(false);
}
};
return {
creating,
saving,
deletingUserId,
syncingCwa,
createUser,
saveEditedUser,
deleteUser,
syncCwaUsers,
};
};
@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react';
import {
AdminUser,
DeliveryPreferencesResponse,
DownloadDefaults,
getAdminDeliveryPreferences,
getAdminUser,
getAdminUsers,
getDownloadDefaults,
} from '../../../services/api';
import { PerUserSettings } from './types';
interface UseUsersFetchParams {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export interface UserEditContext {
user: AdminUser;
downloadDefaults: DownloadDefaults;
deliveryPreferences: DeliveryPreferencesResponse | null;
userSettings: PerUserSettings;
userOverridableSettings: Set<string>;
}
export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setLoadError(null);
const data = await getAdminUsers();
setUsers(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load users';
setLoadError(message);
onShowToast?.(message, 'error');
} finally {
setLoading(false);
}
}, [onShowToast]);
useEffect(() => {
void fetchUsers();
}, [fetchUsers]);
const fetchUserEditContext = useCallback(async (userId: number): Promise<UserEditContext> => {
const [fullUser, defaults] = await Promise.all([
getAdminUser(userId),
getDownloadDefaults(),
]);
let deliveryPreferences: DeliveryPreferencesResponse | null = null;
let userSettings = (fullUser.settings || {}) as PerUserSettings;
let userOverridableSettings = new Set<string>();
try {
const preferences = await getAdminDeliveryPreferences(userId);
deliveryPreferences = preferences;
userSettings = (preferences.userOverrides || fullUser.settings || {}) as PerUserSettings;
userOverridableSettings = new Set(preferences.keys || []);
} catch {
// Delivery preference introspection is best-effort.
}
return {
user: fullUser,
downloadDefaults: defaults,
deliveryPreferences,
userSettings,
userOverridableSettings,
};
}, []);
return {
users,
loading,
loadError,
fetchUsers,
fetchUserEditContext,
};
};
@@ -0,0 +1,30 @@
import { useCallback, useState } from 'react';
import { UsersPanelRoute } from './types';
export const useUsersPanelState = () => {
const [route, setRoute] = useState<UsersPanelRoute>({ kind: 'list' });
const openCreate = useCallback(() => {
setRoute({ kind: 'create' });
}, []);
const openEdit = useCallback((userId: number) => {
setRoute({ kind: 'edit', userId });
}, []);
const openEditOverrides = useCallback((userId: number) => {
setRoute({ kind: 'edit-overrides', userId });
}, []);
const backToList = useCallback(() => {
setRoute({ kind: 'list' });
}, []);
return {
route,
openCreate,
openEdit,
openEditOverrides,
backToList,
};
};
@@ -87,6 +87,8 @@ export function Tooltip({
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocusCapture={showTooltip}
onBlurCapture={hideTooltip}
className="inline-flex"
>
{children}
+24 -16
View File
@@ -14,6 +14,9 @@ interface UseAuthReturn {
authChecked: boolean;
isAdmin: boolean;
authMode: string;
username: string | null;
displayName: string | null;
oidcButtonLabel: string | null;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
@@ -30,23 +33,27 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [authMode, setAuthMode] = useState<string>('none');
const [username, setUsername] = useState<string | null>(null);
const [displayName, setDisplayName] = useState<string | null>(null);
const [oidcButtonLabel, setOidcButtonLabel] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
const applyAuthResponse = useCallback((response: Awaited<ReturnType<typeof checkAuth>>) => {
setAuthRequired(response.auth_required !== false);
setIsAuthenticated(response.authenticated || false);
setIsAdmin(response.is_admin || false);
setAuthMode(response.auth_mode || 'none');
setUsername(response.username || null);
setDisplayName(response.display_name || null);
setOidcButtonLabel(response.oidc_button_label || null);
}, []);
// Check authentication on mount
useEffect(() => {
const verifyAuth = async () => {
try {
const response = await checkAuth();
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false;
const admin = response.is_admin || false;
const mode = response.auth_mode || 'none';
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
setIsAdmin(admin);
setAuthMode(mode);
applyAuthResponse(await checkAuth());
} catch (error) {
console.error('Auth check failed:', error);
setAuthRequired(true);
@@ -57,7 +64,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
}
};
verifyAuth();
}, []);
}, [applyAuthResponse]);
const handleLogin = useCallback(async (credentials: LoginCredentials) => {
setIsLoggingIn(true);
@@ -65,10 +72,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
try {
const response = await login(credentials);
if (response.success) {
// Re-check auth to get updated admin status from session
const authResponse = await checkAuth();
setIsAuthenticated(true);
setIsAdmin(authResponse.is_admin || false);
// Re-check auth to get updated session state
applyAuthResponse(await checkAuth());
setLoginError(null);
navigate('/', { replace: true });
} else {
@@ -83,7 +88,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
} finally {
setIsLoggingIn(false);
}
}, [navigate]);
}, [navigate, applyAuthResponse]);
const handleLogout = useCallback(async () => {
try {
@@ -107,6 +112,9 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
authChecked,
isAdmin,
authMode,
username,
displayName,
oidcButtonLabel,
loginError,
isLoggingIn,
setIsAuthenticated,
+7 -7
View File
@@ -7,9 +7,10 @@ interface LoginPageProps {
error: string | null;
isLoading: boolean;
authMode?: string;
oidcButtonLabel?: string | null;
}
export const LoginPage = ({ onLogin, error, isLoading, authMode }: LoginPageProps) => {
export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel }: LoginPageProps) => {
const logoUrl = withBasePath('/logo.png');
return (
@@ -18,19 +19,18 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode }: LoginPageProp
style={{ backgroundColor: 'var(--background-color)', color: 'var(--text-color)' }}
>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img src={logoUrl} alt="Logo" className="mx-auto mb-6 w-20 h-20" />
<h1 className="text-2xl font-semibold">Sign in to continue</h1>
</div>
<div
className="rounded-lg shadow-2xl p-8 border"
className="rounded-lg shadow-2xl p-6 border"
style={{
backgroundColor: 'var(--card-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} />
<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} />
</div>
</div>
</div>
+72 -7
View File
@@ -1,5 +1,5 @@
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse, ReleaseSource, ReleasesResponse } from '../types';
import { SettingsResponse, ActionResult, UpdateResult } from '../types/settings';
import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings';
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
import { getApiBase } from '../utils/basePath';
@@ -189,12 +189,9 @@ export const getMetadataBookInfo = async (provider: string, bookId: string): Pro
return transformMetadataToBook(response);
};
export const downloadBook = async (id: string, emailRecipient?: string): Promise<void> => {
export const downloadBook = async (id: string): Promise<void> => {
const params = new URLSearchParams();
params.set('id', id);
if (emailRecipient) {
params.set('email_recipient', emailRecipient);
}
await fetchJSON(`${API.download}?${params.toString()}`);
};
@@ -219,7 +216,6 @@ export const downloadRelease = async (release: {
series_position?: number;
subtitle?: string;
search_author?: string;
email_recipient?: string;
}): Promise<void> => {
await fetchJSON(`${API_BASE}/releases/download`, {
method: 'POST',
@@ -266,6 +262,10 @@ export const getSettings = async (): Promise<SettingsResponse> => {
return fetchJSON<SettingsResponse>(API.settings);
};
export const getSettingsTab = async (tabName: string): Promise<SettingsTab> => {
return fetchJSON<SettingsTab>(`${API.settings}/${tabName}`);
};
export const updateSettings = async (
tabName: string,
values: Record<string, unknown>
@@ -382,14 +382,27 @@ export const getReleases = async (
// Admin user management API
export type AdminAuthSource = 'builtin' | 'oidc' | 'proxy' | 'cwa';
export interface AdminUserEditCapabilities {
authSource: AdminAuthSource;
canSetPassword: boolean;
canEditRole: boolean;
canEditEmail: boolean;
canEditDisplayName: boolean;
}
export interface AdminUser {
id: number;
username: string;
email: string | null;
display_name: string | null;
role: string;
auth_source: AdminAuthSource;
is_active: boolean;
oidc_subject: string | null;
created_at: string;
edit_capabilities: AdminUserEditCapabilities;
settings?: Record<string, unknown>;
}
@@ -429,12 +442,27 @@ export const deleteAdminUser = async (userId: number): Promise<{ success: boolea
});
};
export interface CwaUserSyncResult {
success: boolean;
message: string;
created: number;
updated: number;
total: number;
}
export const syncAdminCwaUsers = async (): Promise<CwaUserSyncResult> => {
return fetchJSON<CwaUserSyncResult>(`${API_BASE}/admin/users/sync-cwa`, {
method: 'POST',
});
};
export interface DownloadDefaults {
BOOKS_OUTPUT_MODE: string;
DESTINATION: string;
DESTINATION_AUDIOBOOK: string;
BOOKLORE_LIBRARY_ID: string;
BOOKLORE_PATH_ID: string;
EMAIL_RECIPIENTS: Array<{ nickname: string; email: string }>;
EMAIL_RECIPIENT: string;
OIDC_ADMIN_GROUP: string;
OIDC_USE_ADMIN_GROUP: boolean;
OIDC_AUTO_PROVISION: boolean;
@@ -458,3 +486,40 @@ export interface BookloreOptions {
export const getBookloreOptions = async (): Promise<BookloreOptions> => {
return fetchJSON<BookloreOptions>(`${API_BASE}/admin/booklore-options`);
};
export interface DeliveryPreferencesResponse {
tab: string;
keys: string[];
fields: import('../types/settings').SettingsField[];
globalValues: Record<string, unknown>;
userOverrides: Record<string, unknown>;
effective: Record<string, { value: unknown; source: string }>;
}
export const getAdminDeliveryPreferences = async (
userId: number
): Promise<DeliveryPreferencesResponse> => {
return fetchJSON<DeliveryPreferencesResponse>(`${API_BASE}/admin/users/${userId}/delivery-preferences`);
};
export interface SettingsOverrideUserDetail {
userId: number;
username: string;
value: unknown;
}
export interface SettingsOverrideKeySummary {
count: number;
users: SettingsOverrideUserDetail[];
}
export interface SettingsOverridesSummaryResponse {
tab: string;
keys: Record<string, SettingsOverrideKeySummary>;
}
export const getAdminSettingsOverridesSummary = async (
tabName: string
): Promise<SettingsOverridesSummaryResponse> => {
return fetchJSON<SettingsOverridesSummaryResponse>(`${API_BASE}/admin/settings/overrides-summary?tab=${encodeURIComponent(tabName)}`);
};
+3 -6
View File
@@ -152,11 +152,6 @@ export type ContentType = 'ebook' | 'audiobook';
export type BooksOutputMode = 'folder' | 'booklore' | 'email';
export interface EmailRecipient {
nickname: string;
email: string;
}
export interface AppConfig {
calibre_web_url: string;
audiobook_library_url: string;
@@ -172,7 +167,6 @@ export interface AppConfig {
metadata_search_fields: MetadataSearchField[];
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
books_output_mode: BooksOutputMode;
email_recipients: EmailRecipient[];
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
download_to_browser: boolean; // Auto-download completed files to browser
settings_enabled: boolean; // Whether config directory is mounted and writable
@@ -194,8 +188,11 @@ export interface AuthResponse {
auth_required?: boolean;
auth_mode?: string;
is_admin?: boolean;
username?: string;
display_name?: string | null;
error?: string;
logout_url?: string;
oidc_button_label?: string;
}
// Type guard to check if a book is from a metadata provider
+1
View File
@@ -49,6 +49,7 @@ export interface BaseField {
showWhen?: ShowWhen; // Conditional visibility based on another field's value
disabledWhen?: DisabledWhenCondition; // Conditional disable based on another field's value
requiresRestart?: boolean; // True if changing this setting requires a container restart
userOverridable?: boolean; // True when this field supports per-user overrides
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
}