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
+22
View File
@@ -11,6 +11,7 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button';
import { VersionBadge } from '@/components/ui/VersionBadge';
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export function Header() {
@@ -18,8 +19,12 @@ export function Header() {
const [showUserMenu, setShowUserMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
// Check if user can change password (local users only)
const canChangePassword = user?.authProvider === 'local';
// Check if BookDate is configured
useEffect(() => {
async function checkBookDate() {
@@ -85,6 +90,17 @@ export function Header() {
>
Profile
</Link>
{canChangePassword && (
<button
onClick={() => {
setShowUserMenu(false);
setShowChangePasswordModal(true);
}}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Change Password
</button>
)}
<button
onClick={() => {
logout();
@@ -268,6 +284,12 @@ export function Header() {
{/* User menu dropdown (rendered via portal) */}
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
{/* Change Password Modal */}
<ChangePasswordModal
isOpen={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
/>
</header>
);
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Component: Change Password Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { Input } from './Input';
import { Button } from './Button';
interface ChangePasswordModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Validation errors for individual fields
const [errors, setErrors] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const validateForm = (): boolean => {
const newErrors = {
currentPassword: '',
newPassword: '',
confirmPassword: '',
};
let isValid = true;
if (!currentPassword) {
newErrors.currentPassword = 'Current password is required';
isValid = false;
}
if (!newPassword) {
newErrors.newPassword = 'New password is required';
isValid = false;
} else if (newPassword.length < 8) {
newErrors.newPassword = 'Password must be at least 8 characters';
isValid = false;
} else if (newPassword === currentPassword) {
newErrors.newPassword = 'New password must be different from current password';
isValid = false;
}
if (!confirmPassword) {
newErrors.confirmPassword = 'Please confirm your new password';
isValid = false;
} else if (newPassword !== confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
isValid = false;
}
setErrors(newErrors);
return isValid;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Clear previous error and success states
setError(null);
setSuccess(false);
// Validate form
if (!validateForm()) {
return;
}
setLoading(true);
try {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
throw new Error('Not authenticated');
}
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setSuccess(true);
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setErrors({ currentPassword: '', newPassword: '', confirmPassword: '' });
// Auto-close after 2 seconds
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
} else {
setError(data.error || 'Failed to change password');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change password');
} finally {
setLoading(false);
}
};
const handleClose = () => {
// Reset form state when closing
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
setSuccess(false);
setErrors({ currentPassword: '', newPassword: '', confirmPassword: '' });
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Change Password" size="sm">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Success message */}
{success && (
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center">
<svg
className="w-5 h-5 text-green-600 dark:text-green-400 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<p className="text-sm text-green-800 dark:text-green-200">
Password changed successfully!
</p>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center">
<svg
className="w-5 h-5 text-red-600 dark:text-red-400 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
)}
{/* Current Password */}
<Input
type="password"
label="Current Password"
value={currentPassword}
onChange={(e) => {
setCurrentPassword(e.target.value);
setErrors({ ...errors, currentPassword: '' });
}}
placeholder="Enter your current password"
autoComplete="current-password"
error={errors.currentPassword}
disabled={loading || success}
/>
{/* New Password */}
<Input
type="password"
label="New Password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setErrors({ ...errors, newPassword: '' });
}}
placeholder="Enter your new password"
autoComplete="new-password"
helperText="Must be at least 8 characters"
error={errors.newPassword}
disabled={loading || success}
/>
{/* Confirm New Password */}
<Input
type="password"
label="Confirm New Password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setErrors({ ...errors, confirmPassword: '' });
}}
placeholder="Confirm your new password"
autoComplete="new-password"
error={errors.confirmPassword}
disabled={loading || success}
/>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading || success}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={loading}
disabled={loading || success}
>
Change Password
</Button>
</div>
</form>
</Modal>
);
}