mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 07:31:13 +01:00
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:
+11
-73
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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)}`);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user