Add OIDC authentication and multi-user support (#606)

Closes #552

## Summary

Adds OIDC authentication and multi-user support to Shelfmark. Users can
now be managed individually with per-user download settings, while
maintaining full backwards compatibility with existing auth modes
(no-auth, builtin, proxy, CWA).

### Authentication
- **OIDC login** with PKCE, auto-discovery, group-based admin mapping
- **Password fallback** when OIDC is enabled (prevents admin lockout)
- **Auto-provisioning** of OIDC users (configurable on/off)
- **Email-based linking** of pre-created users to OIDC accounts
- **Lockout prevention** — requires a local admin before OIDC can be
enabled

### User Management
- **SQLite user database** (`users.db`) with admin CRUD API
- **Users management tab** in settings UI (admin-only)
- **Settings restricted to admins** in multi-user modes (builtin/OIDC) —
non-admin users cannot access settings
- Create, edit, and delete users with role assignment (admin/user)
- Password management for builtin auth users
- OIDC users shown with provider badge (password fields hidden)
- Per-user configurable settings:
  - **Download destination** — custom folder path per user
- **BookLore library & path** — dropdown select, each user's books go to
their own library
  - **Email recipients** — per-user email delivery targets
- **`{User}` template variable** — use in destination paths (e.g.,
`/books/{User}/`)
- Settings override model: per-user values override globals, empty/unset
falls back to global defaults

### Download Scoping
- **Per-user download visibility** — non-admins only see their own
downloads
- **Username display** in downloads sidebar (shows who requested each
download)
- **WebSocket room-based filtering** — admins see all, users see only
their own
- **Download progress scoping** — progress events routed to correct user
rooms

### BookLore Integration
- **Dynamic dropdown selects** for library/path (replaces text inputs)
- **Per-user library/path overrides** via user settings
- **Options cache refresh** after Test Connection

### Security
- SQL injection prevention (column whitelist on user updates)
- Generic OIDC error messages (no internal detail leakage)
- Admin self-deletion and last-local-admin deletion guards
- OIDC role overwrite fix (only updates role when admin_group is
configured)

## Migration

**No migration script needed.** The `users.db` is created automatically
on first startup. Existing builtin auth users are auto-migrated to the
database on their first login. All other auth modes (no-auth, proxy,
CWA) continue working unchanged.

## Test Plan

- [x] All 519 tests passing, 0 failures
- [ ] Test no-auth mode: settings accessible, downloads work without
login
- [ ] Test builtin auth: legacy credentials auto-migrate on login, new
users can be created
- [ ] Test OIDC auth: login flow, callback, auto-provisioning,
group-based admin
- [ ] Test CWA auth: unchanged behavior
- [ ] Test proxy auth: unchanged behavior
- [ ] Test per-user downloads: non-admin sees only own downloads
- [ ] Test BookLore dropdowns: library/path selection, per-user
overrides
- [ ] Test Docker build: no Dockerfile changes needed

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Joshua Saul
2026-02-11 17:44:27 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b5923635a6
commit 2d2f54729f
43 changed files with 4646 additions and 102 deletions
-9
View File
@@ -69,7 +69,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1217,7 +1216,6 @@
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1235,7 +1233,6 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1437,7 +1434,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -2001,7 +1997,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -2322,7 +2317,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2492,7 +2486,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2505,7 +2498,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -3090,7 +3082,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+2
View File
@@ -79,6 +79,7 @@ function App() {
authRequired,
authChecked,
isAdmin,
authMode,
loginError,
isLoggingIn,
setIsAuthenticated,
@@ -854,6 +855,7 @@ function App() {
onLogin={handleLogin}
error={loginError}
isLoading={isLoggingIn}
authMode={authMode}
/>
)
}
@@ -241,6 +241,12 @@ export const DownloadsSidebar = ({
<span>{book.source_display_name}</span>
</>
)}
{book.username && (
<>
<span> • </span>
<span>{book.username}</span>
</>
)}
</div>
{/* Status Badge */}
+22
View File
@@ -7,6 +7,7 @@ interface LoginFormProps {
error?: string | null;
isLoading?: boolean;
autoFocus?: boolean;
authMode?: string;
}
const EyeIcon = () => (
@@ -53,6 +54,7 @@ export const LoginForm = ({
error = null,
isLoading = false,
autoFocus = true,
authMode,
}: LoginFormProps) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -225,6 +227,26 @@ export const LoginForm = ({
)}
</button>
</form>
{authMode === 'oidc' && (
<>
<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)',
}}
>
Sign in with OIDC
</a>
</>
)}
</div>
);
};
@@ -4,6 +4,7 @@ import { useSearchMode } from '../../contexts/SearchModeContext';
import { SettingsHeader } from './SettingsHeader';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsContent } from './SettingsContent';
import { UsersPanel } from './UsersPanel';
interface SettingsModalProps {
isOpen: boolean;
@@ -149,11 +150,37 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
// Memoize the field change handler to prevent creating new functions on every render
const handleFieldChange = useCallback(
(key: string, value: unknown) => {
if (selectedTab) {
updateValue(selectedTab, key, value);
if (!selectedTab) return;
updateValue(selectedTab, key, value);
// Auto-manage OIDC scopes when admin group settings change
if (selectedTab === 'security') {
const tabValues = values[selectedTab] || {};
const currentScopes = (tabValues['OIDC_SCOPES'] as string[]) || [];
if (key === 'OIDC_USE_ADMIN_GROUP') {
const groupClaim = (tabValues['OIDC_GROUP_CLAIM'] as string) || 'groups';
if (value === true && !currentScopes.includes(groupClaim)) {
updateValue(selectedTab, 'OIDC_SCOPES', [...currentScopes, groupClaim]);
} else if (value === false && currentScopes.includes(groupClaim)) {
updateValue(selectedTab, 'OIDC_SCOPES', currentScopes.filter(s => s !== groupClaim));
}
}
if (key === 'OIDC_GROUP_CLAIM' && typeof value === 'string') {
const useAdminGroup = tabValues['OIDC_USE_ADMIN_GROUP'] as boolean;
if (useAdminGroup) {
const oldClaim = (tabValues['OIDC_GROUP_CLAIM'] as string) || 'groups';
const newScopes = currentScopes.filter(s => s !== oldClaim);
if (value && !newScopes.includes(value)) {
newScopes.push(value);
}
updateValue(selectedTab, 'OIDC_SCOPES', newScopes);
}
}
}
},
[selectedTab, updateValue]
[selectedTab, updateValue, values]
);
// Memoize hasChanges to avoid expensive JSON.stringify comparisons on every render
@@ -280,16 +307,20 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
onClose={handleClose}
/>
{currentTab && (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
)}
</>
)}
@@ -331,16 +362,20 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
/>
{currentTab ? (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-sm opacity-60">
Select a category to configure
@@ -62,6 +62,12 @@ const getIcon = (iconName?: string) => {
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
);
case 'users':
return (
<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 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
);
case 'beaker':
case 'wrench':
return (
@@ -0,0 +1,743 @@
import { useCallback, useEffect, useState } from 'react';
import {
AdminUser,
BookloreOption,
DownloadDefaults,
getAdminUsers,
getAdminUser,
getBookloreOptions,
getDownloadDefaults,
createAdminUser,
updateAdminUser,
deleteAdminUser,
} from '../../services/api';
interface UsersPanelProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => 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';
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';
interface PerUserSettings {
destination?: string;
booklore_library_id?: string;
booklore_path_id?: string;
email_recipients?: Array<{ nickname: string; email: string }>;
}
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);
// 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 () => {
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,
});
} 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');
}
};
const handleSaveEdit = async () => {
if (!editingUser) return;
// 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;
}
}
// 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 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 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;
});
}
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center text-sm opacity-60 p-8">
Loading users...
</div>
);
}
if (loadError) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-3">
<p className="text-sm opacity-60">{loadError}</p>
<button
onClick={fetchUsers}
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"
>
Retry
</button>
</div>
);
}
// Edit view
if (editingUser) {
const outputMode = downloadDefaults?.BOOKS_OUTPUT_MODE || 'folder';
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setEditingUser(null)}
className="text-sm opacity-60 hover:opacity-100 transition-opacity"
>
&larr; Back
</button>
<h3 className="text-sm font-medium">Edit {editingUser.username}</h3>
</div>
<div className="space-y-5 max-w-lg">
{editingUser.oidc_subject && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
This user authenticates via SSO. Password is managed by the identity provider.
</div>
{downloadDefaults?.OIDC_USE_ADMIN_GROUP === true && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
{downloadDefaults?.OIDC_ADMIN_GROUP
? `Admin role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Admin group authorization is enabled but no group name is configured.'}
</div>
)}
</div>
)}
<div className="space-y-1.5">
<label className="text-sm font-medium">Display Name</label>
<input
type="text"
value={editingUser.display_name || ''}
onChange={(e) => setEditingUser({ ...editingUser, display_name: e.target.value || null })}
className={inputClasses}
placeholder="Display name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={editingUser.email || ''}
onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value || null })}
className={inputClasses}
placeholder="user@example.com"
/>
</div>
{/* Hide role dropdown for OIDC users when admin group auth is on (like password) */}
{!(!!editingUser.oidc_subject && downloadDefaults?.OIDC_USE_ADMIN_GROUP === true) && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Role</label>
<select
value={editingUser.role}
onChange={(e) => setEditingUser({ ...editingUser, role: e.target.value })}
className={inputClasses}
>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
)}
{/* Password section */}
{!editingUser.oidc_subject && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-3">Change Password</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">New Password</label>
<input
type="password"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className={inputClasses}
placeholder="Leave empty to keep current"
/>
</div>
{editPassword && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Confirm Password</label>
<input
type="password"
value={editPasswordConfirm}
onChange={(e) => setEditPasswordConfirm(e.target.value)}
className={inputClasses}
placeholder="Confirm new password"
/>
</div>
)}
</>
)}
{/* Per-user download settings overrides */}
{downloadDefaults && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-1">Download Settings Overrides</p>
<p className="text-xs opacity-40 mb-3">Override global defaults for this user.</p>
</div>
{/* Destination override (shown for folder mode) */}
{(outputMode === 'folder' || outputMode === 'booklore') && (
<OverrideField
label="Destination Folder"
enabled={overrides.destination || false}
onToggle={(v) => toggleOverride('destination', v)}
globalValue={downloadDefaults.DESTINATION || '/books'}
>
<input
type="text"
value={userSettings.destination || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, destination: e.target.value }))}
className={overrides.destination ? inputClasses : disabledInputClasses}
disabled={!overrides.destination}
placeholder={downloadDefaults.DESTINATION || '/books'}
/>
</OverrideField>
)}
{/* BookLore overrides */}
{outputMode === 'booklore' && (
<>
<OverrideField
label="BookLore Library"
enabled={overrides.booklore_library_id || false}
onToggle={(v) => toggleOverride('booklore_library_id', v)}
globalValue={
bookloreLibraries.find((l) => l.value === downloadDefaults.BOOKLORE_LIBRARY_ID)?.label
|| downloadDefaults.BOOKLORE_LIBRARY_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_library_id || ''}
onChange={(e) => {
setUserSettings((s) => ({ ...s, booklore_library_id: e.target.value, booklore_path_id: '' }));
// Reset path override when library changes
if (overrides.booklore_path_id) {
setOverrides((o) => ({ ...o, booklore_path_id: true }));
}
}}
className={overrides.booklore_library_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_library_id}
>
<option value="">Select library...</option>
{bookloreLibraries.map((lib) => (
<option key={lib.value} value={lib.value}>{lib.label}</option>
))}
</select>
</OverrideField>
<OverrideField
label="BookLore Path"
enabled={overrides.booklore_path_id || false}
onToggle={(v) => toggleOverride('booklore_path_id', v)}
globalValue={
booklorePaths.find((p) => p.value === downloadDefaults.BOOKLORE_PATH_ID)?.label
|| downloadDefaults.BOOKLORE_PATH_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_path_id || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, booklore_path_id: e.target.value }))}
className={overrides.booklore_path_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_path_id}
>
<option value="">Select path...</option>
{booklorePaths
.filter((p) => {
const selectedLib = userSettings.booklore_library_id || downloadDefaults.BOOKLORE_LIBRARY_ID;
return !p.childOf || p.childOf === selectedLib;
})
.map((path) => (
<option key={path.value} value={path.value}>{path.label}</option>
))}
</select>
</OverrideField>
</>
)}
{/* Email recipients override */}
{outputMode === 'email' && (
<OverrideField
label="Email Recipients"
enabled={overrides.email_recipients || false}
onToggle={(v) => toggleOverride('email_recipients', v)}
globalValue={
downloadDefaults.EMAIL_RECIPIENTS?.length
? downloadDefaults.EMAIL_RECIPIENTS.map((r) => r.nickname || r.email).join(', ')
: 'None configured'
}
>
{overrides.email_recipients && (
<EmailRecipientsEditor
recipients={userSettings.email_recipients || []}
onChange={(r) => setUserSettings((s) => ({ ...s, email_recipients: r }))}
/>
)}
</OverrideField>
)}
</>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleSaveEdit}
className="px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
>
Save Changes
</button>
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2.5 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}
// 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>
</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>
);
};
@@ -6,6 +6,7 @@ interface TagListFieldProps {
value: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
requiredTags?: string[]; // Tags that cannot be removed
}
function normalizeTag(raw: string): string {
@@ -24,15 +25,18 @@ function normalizeTag(raw: string): string {
if (s.toLowerCase() === 'auto') return '';
// Basic URL normalization to keep UX friendly; backend also normalizes on save.
if (!s.includes('://') && !s.startsWith('/')) {
// Only add https:// if it looks like a domain (contains a dot) and has no protocol.
// This avoids adding prefixes to non-URL values like OIDC scopes (openid, email, etc.)
if (!s.includes('://') && !s.startsWith('/') && s.includes('.')) {
s = `https://${s}`;
}
s = s.replace(/\/+$/, '');
return s.trim();
}
export const TagListField = ({ field, value, onChange, disabled }: TagListFieldProps) => {
export const TagListField = ({ field, value, onChange, disabled, requiredTags }: TagListFieldProps) => {
const isDisabled = disabled ?? false;
const required = requiredTags ?? [];
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState('');
@@ -59,8 +63,10 @@ export const TagListField = ({ field, value, onChange, disabled }: TagListFieldP
}
};
const isRequired = (tag: string) => required.includes(tag);
const removeAt = (idx: number) => {
if (isDisabled) return;
if (isDisabled || isRequired(tags[idx])) return;
onChange(tags.filter((_, i) => i !== idx));
};
@@ -91,7 +97,7 @@ export const TagListField = ({ field, value, onChange, disabled }: TagListFieldP
title={tag}
>
<span className="truncate max-w-[22rem]">{tag}</span>
{!isDisabled && (
{!isDisabled && !isRequired(tag) && (
<button
type="button"
onClick={(e) => {
+5
View File
@@ -13,6 +13,7 @@ interface UseAuthReturn {
authRequired: boolean;
authChecked: boolean;
isAdmin: boolean;
authMode: string;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
@@ -28,6 +29,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [authMode, setAuthMode] = useState<string>('none');
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
@@ -39,10 +41,12 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
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);
} catch (error) {
console.error('Auth check failed:', error);
setAuthRequired(true);
@@ -102,6 +106,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
authRequired,
authChecked,
isAdmin,
authMode,
loginError,
isLoggingIn,
setIsAuthenticated,
+11 -2
View File
@@ -108,6 +108,7 @@ export function useSettings(): UseSettingsReturn {
}
});
});
setValues(initialValues);
setOriginalValues(JSON.parse(JSON.stringify(initialValues)));
@@ -239,7 +240,15 @@ export function useSettings(): UseSettingsReturn {
try {
// Pass current form values so action can use unsaved values
const currentValues = values[tabName] || {};
return await executeSettingsAction(tabName, actionKey, currentValues);
const result = await executeSettingsAction(tabName, actionKey, currentValues);
// Re-fetch settings after successful action to pick up updated options
// (e.g., BookLore "Test Connection" refreshes library/path lists)
if (result.success) {
fetchSettings(true);
}
return result;
} catch (err) {
console.error('Action execution failed:', tabName, actionKey, err);
return {
@@ -248,7 +257,7 @@ export function useSettings(): UseSettingsReturn {
};
}
},
[values]
[values, fetchSettings]
);
return {
+3 -2
View File
@@ -6,9 +6,10 @@ interface LoginPageProps {
onLogin: (credentials: LoginCredentials) => void;
error: string | null;
isLoading: boolean;
authMode?: string;
}
export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
export const LoginPage = ({ onLogin, error, isLoading, authMode }: LoginPageProps) => {
const logoUrl = withBasePath('/logo.png');
return (
@@ -29,7 +30,7 @@ export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
color: 'var(--text-color)',
}}
>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} />
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} />
</div>
</div>
</div>
+79
View File
@@ -379,3 +379,82 @@ export const getReleases = async (
// Let the backend control timeouts for release searches (can be long-running).
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, null);
};
// Admin user management API
export interface AdminUser {
id: number;
username: string;
email: string | null;
display_name: string | null;
role: string;
oidc_subject: string | null;
created_at: string;
settings?: Record<string, unknown>;
}
export const getAdminUsers = async (): Promise<AdminUser[]> => {
return fetchJSON<AdminUser[]>(`${API_BASE}/admin/users`);
};
export const getAdminUser = async (userId: number): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/admin/users/${userId}`);
};
export const createAdminUser = async (
data: { username: string; password: string; email?: string; display_name?: string; role?: string }
): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/admin/users`, {
method: 'POST',
body: JSON.stringify(data),
});
};
export const updateAdminUser = async (
userId: number,
data: Partial<Pick<AdminUser, 'role' | 'email' | 'display_name'>> & {
password?: string;
settings?: Record<string, unknown>;
}
): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/admin/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
};
export const deleteAdminUser = async (userId: number): Promise<{ success: boolean }> => {
return fetchJSON<{ success: boolean }>(`${API_BASE}/admin/users/${userId}`, {
method: 'DELETE',
});
};
export interface DownloadDefaults {
BOOKS_OUTPUT_MODE: string;
DESTINATION: string;
BOOKLORE_LIBRARY_ID: string;
BOOKLORE_PATH_ID: string;
EMAIL_RECIPIENTS: Array<{ nickname: string; email: string }>;
OIDC_ADMIN_GROUP: string;
OIDC_USE_ADMIN_GROUP: boolean;
OIDC_AUTO_PROVISION: boolean;
}
export const getDownloadDefaults = async (): Promise<DownloadDefaults> => {
return fetchJSON<DownloadDefaults>(`${API_BASE}/admin/download-defaults`);
};
export interface BookloreOption {
value: string;
label: string;
childOf?: string;
}
export interface BookloreOptions {
libraries: BookloreOption[];
paths: BookloreOption[];
}
export const getBookloreOptions = async (): Promise<BookloreOptions> => {
return fetchJSON<BookloreOptions>(`${API_BASE}/admin/booklore-options`);
};
+2
View File
@@ -48,6 +48,7 @@ export interface Book {
subtitle?: string;
search_title?: string;
search_author?: string;
username?: string;
}
// Status response types
@@ -191,6 +192,7 @@ export interface AuthResponse {
success?: boolean;
authenticated?: boolean;
auth_required?: boolean;
auth_mode?: string;
is_admin?: boolean;
error?: string;
logout_url?: string;