Add Audible region config and user password change modal

Implements configurable Audible region selection in setup and admin settings, affecting all Audible API calls and triggering data refresh on change. Adds a user-facing 'Change Password' modal in the header for local users, moving password change from admin-only to all local users via a new /api/auth/change-password endpoint. Updates documentation, API routes, and context to support these features, and removes the old admin-only password change flow.
This commit is contained in:
kikootwo
2026-01-13 01:51:22 -05:00
parent 50fb5a68af
commit e346f88f42
24 changed files with 932 additions and 317 deletions
+78 -143
View File
@@ -34,6 +34,7 @@ interface IndexerConfig {
interface Settings {
backendMode: 'plex' | 'audiobookshelf';
hasLocalUsers: boolean;
audibleRegion: string;
plex: {
url: string;
token: string;
@@ -136,15 +137,7 @@ export default function AdminSettings() {
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(
null
);
const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'account' | 'bookdate'>('library');
// Password change form state
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [changingPassword, setChangingPassword] = useState(false);
const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate'>('library');
// BookDate configuration state
const [bookdateProvider, setBookdateProvider] = useState<string>('openai');
@@ -834,41 +827,6 @@ export default function AdminSettings() {
}
};
const changePassword = async () => {
setChangingPassword(true);
setMessage(null);
try {
const response = await fetchWithAuth('/api/admin/settings/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(passwordForm),
});
const data = await response.json();
if (data.success) {
setMessage({ type: 'success', text: 'Password changed successfully!' });
// Clear form
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
setTimeout(() => setMessage(null), 5000);
} else {
setMessage({ type: 'error', text: data.error || 'Failed to change password' });
}
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to change password',
});
} finally {
setChangingPassword(false);
}
};
const saveSettings = async () => {
if (!settings) return;
@@ -879,6 +837,18 @@ export default function AdminSettings() {
// Save settings based on active tab
switch (activeTab) {
case 'library':
// Save Audible region (common to both backends)
const audibleResponse = await fetchWithAuth('/api/admin/settings/audible', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region: settings.audibleRegion }),
});
if (!audibleResponse.ok) {
throw new Error('Failed to save Audible region settings');
}
// Save backend-specific settings
if (settings.backendMode === 'plex') {
const plexResponse = await fetchWithAuth('/api/admin/settings/plex', {
method: 'PUT',
@@ -1039,7 +1009,6 @@ export default function AdminSettings() {
{ id: 'paths', label: 'Paths', icon: '📁' },
{ id: 'ebook', label: 'E-book Sidecar', icon: '📖' },
{ id: 'bookdate', label: 'BookDate', icon: '📚' },
...(isLocalAdmin ? [{ id: 'account', label: 'Account', icon: '🔒' }] : []),
];
return (
@@ -1250,6 +1219,37 @@ export default function AdminSettings() {
</label>
</div>
{/* Audible Region Selection */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
<label
htmlFor="audible-region"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Audible Region
</label>
<select
id="audible-region"
value={settings.audibleRegion || 'us'}
onChange={(e) => {
setSettings({
...settings,
audibleRegion: e.target.value,
});
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Plex. This ensures accurate book matching and metadata.
</p>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
onClick={testPlexConnection}
@@ -1385,6 +1385,37 @@ export default function AdminSettings() {
</label>
</div>
{/* Audible Region Selection */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
<label
htmlFor="audible-region-abs"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Audible Region
</label>
<select
id="audible-region-abs"
value={settings.audibleRegion || 'us'}
onChange={(e) => {
setSettings({
...settings,
audibleRegion: e.target.value,
});
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Audiobookshelf. This ensures accurate book matching and metadata.
</p>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
onClick={testABSConnection}
@@ -3067,106 +3098,10 @@ export default function AdminSettings() {
</div>
)}
{/* Account Tab - Only visible to local admin */}
{activeTab === 'account' && isLocalAdmin && (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Account Security
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Change your local admin account password.
</p>
</div>
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<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 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Local Admin Account</p>
<p>
This password is for your local admin account created during setup.
This is separate from media server authentication and is used to log in to the admin portal.
</p>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Current Password
</label>
<Input
type="password"
value={passwordForm.currentPassword}
onChange={(e) =>
setPasswordForm({ ...passwordForm, currentPassword: e.target.value })
}
placeholder="Enter current password"
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
New Password
</label>
<Input
type="password"
value={passwordForm.newPassword}
onChange={(e) =>
setPasswordForm({ ...passwordForm, newPassword: e.target.value })
}
placeholder="Enter new password"
autoComplete="new-password"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Must be at least 8 characters
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirm New Password
</label>
<Input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) =>
setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })
}
placeholder="Confirm new password"
autoComplete="new-password"
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
onClick={changePassword}
loading={changingPassword}
disabled={
!passwordForm.currentPassword ||
!passwordForm.newPassword ||
!passwordForm.confirmPassword ||
passwordForm.newPassword.length < 8 ||
passwordForm.newPassword !== passwordForm.confirmPassword
}
className="w-full bg-blue-600 hover:bg-blue-700"
>
Change Password
</Button>
</div>
</div>
)}
</div>
{/* Footer - Hide for Account, BookDate, and E-book tabs (they have their own save buttons) */}
{activeTab !== 'account' && activeTab !== 'bookdate' && activeTab !== 'ebook' && (
{/* Footer - Hide for BookDate and E-book tabs (they have their own save buttons) */}
{activeTab !== 'bookdate' && activeTab !== 'ebook' && (
<div className="bg-gray-50 dark:bg-gray-900 px-8 py-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => window.location.reload()}>