mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-06 10:54:45 +01:00
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:
co-authored by
Claude Opus 4.6
parent
b5923635a6
commit
2d2f54729f
Generated
-9
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user