Users with 8-11 character passwords could not export/restore config backups because the export encryption requires 12+ character passphrases for security, but the password creation UI only enforced an 8-character minimum. This created a confusing UX where users with short passwords saw validation errors when trying to export backups, with the only solution being to use a custom passphrase or change their password. Root cause: - FirstRunSetup and ChangePasswordModal allowed 8+ char passwords - Config export/import requires 12+ char passphrases (backend validation) - The v4.26.4 fix added frontend validation that showed the mismatch - Users hit client-side validation before request was sent (no backend logs) This fix raises the minimum password length to 12 characters everywhere: - internal/auth/password.go: MinPasswordLength 8 → 12 - FirstRunSetup.tsx: validation and placeholder updated - ChangePasswordModal.tsx: validation, minLength, and help text updated - QuickSecuritySetup.tsx: validation and label updated Impact: - New users must create 12+ character passwords - Existing users with <12 char passwords are unaffected (can't detect from hash) - Those users will see the existing helpful error directing them to use custom passphrase for backups - "Use your login password" option now works for all future passwords This aligns password requirements across the system and eliminates the confusing mismatch between login credentials and backup encryption requirements. Related to #646 where user confirmed backups still failed in v4.26.5
215 lines
7.2 KiB
TypeScript
215 lines
7.2 KiB
TypeScript
import { Component, createSignal, Show } from 'solid-js';
|
|
import { Portal } from 'solid-js/web';
|
|
import { showSuccess, showError } from '@/utils/toast';
|
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
|
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
|
|
|
|
interface ChangePasswordModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props) => {
|
|
const [currentPassword, setCurrentPassword] = createSignal('');
|
|
const [newPassword, setNewPassword] = createSignal('');
|
|
const [confirmPassword, setConfirmPassword] = createSignal('');
|
|
const [loading, setLoading] = createSignal(false);
|
|
const [error, setError] = createSignal('');
|
|
|
|
const handleSubmit = async (e: Event) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
|
|
// Validation
|
|
if (!currentPassword() || !newPassword() || !confirmPassword()) {
|
|
setError('All fields are required');
|
|
return;
|
|
}
|
|
|
|
if (newPassword() !== confirmPassword()) {
|
|
setError('New passwords do not match');
|
|
return;
|
|
}
|
|
|
|
if (newPassword().length < 12) {
|
|
setError('Password must be at least 12 characters');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Get CSRF token from cookie
|
|
const csrfToken = document.cookie
|
|
.split('; ')
|
|
.find((row) => row.startsWith('pulse_csrf='))
|
|
?.split('=')[1];
|
|
|
|
// Get the actual username from sessionStorage or use 'admin' as fallback
|
|
const authUser = sessionStorage.getItem('pulse_auth_user') || 'admin';
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Basic ${btoa(`${authUser}:${currentPassword()}`)}`,
|
|
};
|
|
|
|
// Add CSRF token if available
|
|
if (csrfToken) {
|
|
headers['X-CSRF-Token'] = csrfToken;
|
|
}
|
|
|
|
const response = await fetch('/api/security/change-password', {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
currentPassword: currentPassword(),
|
|
newPassword: newPassword(),
|
|
}),
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
if (response.status === 401) {
|
|
throw new Error('Current password is incorrect');
|
|
}
|
|
throw new Error(text || 'Failed to change password');
|
|
}
|
|
|
|
showSuccess('Password changed successfully. Please log in with your new password.');
|
|
|
|
// Clear form
|
|
setCurrentPassword('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
|
|
// Close modal and trigger re-authentication
|
|
props.onClose();
|
|
|
|
// Reload page to force re-login with new password
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 2000);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to change password';
|
|
setError(errorMessage);
|
|
showError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (!loading()) {
|
|
setCurrentPassword('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
setError('');
|
|
props.onClose();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Show when={props.isOpen}>
|
|
<Portal>
|
|
<div
|
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
|
style="z-index: 9999"
|
|
>
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
|
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<SectionHeader title="Change password" size="lg" class="flex-1" />
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
disabled={loading()}
|
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
|
|
>
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} class="p-6 space-y-4">
|
|
<div class={formField}>
|
|
<label for="current-password" class={labelClass()}>
|
|
Current Password
|
|
</label>
|
|
<input
|
|
id="current-password"
|
|
type="password"
|
|
value={currentPassword()}
|
|
onInput={(e) => setCurrentPassword(e.currentTarget.value)}
|
|
class={controlClass('shadow-sm')}
|
|
required
|
|
disabled={loading()}
|
|
/>
|
|
</div>
|
|
|
|
<div class={formField}>
|
|
<label for="new-password" class={labelClass()}>
|
|
New Password
|
|
</label>
|
|
<input
|
|
id="new-password"
|
|
type="password"
|
|
value={newPassword()}
|
|
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
|
class={controlClass('shadow-sm')}
|
|
required
|
|
disabled={loading()}
|
|
minLength={12}
|
|
/>
|
|
<p class={`${formHelpText} mt-1`}>Minimum 12 characters</p>
|
|
</div>
|
|
|
|
<div class={formField}>
|
|
<label for="confirm-password" class={labelClass()}>
|
|
Confirm New Password
|
|
</label>
|
|
<input
|
|
id="confirm-password"
|
|
type="password"
|
|
value={confirmPassword()}
|
|
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
|
class={controlClass('shadow-sm')}
|
|
required
|
|
disabled={loading()}
|
|
/>
|
|
</div>
|
|
|
|
<Show when={error()}>
|
|
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
|
<p class="text-sm text-red-600 dark:text-red-400">{error()}</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="flex justify-end space-x-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
disabled={loading()}
|
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loading()}
|
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
|
|
>
|
|
{loading() ? 'Changing...' : 'Change Password'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
</Show>
|
|
);
|
|
};
|