- Add account-settings/ with useAccountSettings, AccountSettingsErrorBanner, AccountSettingsPasswordCard, AccountSettingsExportCard, AccountSettingsDeleteCard, AccountSettingsSkeleton - Re-export from AccountSettings.tsx for backward compatibility - Stories: Default, Loading (skeleton, min-h-layout-story); remove ToastProvider Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
|
import { useAuthStore } from '@/features/auth/store/authStore';
|
|
import { apiClient } from '@/services/api/client';
|
|
import { parseApiError } from '@/utils/apiErrorHandler';
|
|
import { useToast } from '@/hooks/useToast';
|
|
|
|
const MAX_RETRY = 3;
|
|
|
|
export function useAccountSettings() {
|
|
const { logout } = useAuthStore();
|
|
const toast = useToast();
|
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
|
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [passwordError, setPasswordError] = useState('');
|
|
|
|
const [deletePassword, setDeletePassword] = useState('');
|
|
const [deleteReason, setDeleteReason] = useState('');
|
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
|
const [deleteValidationError, setDeleteValidationError] = useState<string | null>(null);
|
|
const [mutationError, setMutationError] = useState<Error | null>(null);
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
const lastMutationRef = useRef<(() => Promise<void>) | null>(null);
|
|
|
|
const handleChangePassword = useCallback(
|
|
async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setPasswordError('');
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
setPasswordError('New passwords do not match');
|
|
return;
|
|
}
|
|
if (newPassword.length < 12) {
|
|
setPasswordError('Password must be at least 12 characters long');
|
|
return;
|
|
}
|
|
|
|
const performMutation = async () => {
|
|
await apiClient.put('/users/me/password', {
|
|
current_password: currentPassword,
|
|
new_password: newPassword,
|
|
});
|
|
toast.success('Password changed successfully');
|
|
setCurrentPassword('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
setMutationError(null);
|
|
setRetryCount(0);
|
|
lastMutationRef.current = null;
|
|
};
|
|
|
|
lastMutationRef.current = performMutation;
|
|
setIsChangingPassword(true);
|
|
try {
|
|
await performMutation();
|
|
} catch (error: unknown) {
|
|
const apiError = parseApiError(error);
|
|
setPasswordError(apiError.message);
|
|
setMutationError(new Error(apiError.message));
|
|
} finally {
|
|
setIsChangingPassword(false);
|
|
}
|
|
},
|
|
[
|
|
currentPassword,
|
|
newPassword,
|
|
confirmPassword,
|
|
toast,
|
|
],
|
|
);
|
|
|
|
const handleDeleteAccount = useCallback(async () => {
|
|
setDeleteValidationError(null);
|
|
setMutationError(null);
|
|
|
|
if (deleteConfirmText !== 'DELETE') {
|
|
setDeleteValidationError('Please type DELETE to confirm');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsDeletingAccount(true);
|
|
await apiClient.delete('/users/me', {
|
|
data: {
|
|
password: deletePassword,
|
|
reason: deleteReason,
|
|
confirm_text: deleteConfirmText,
|
|
},
|
|
});
|
|
toast.success('Account deletion requested. You will be logged out.');
|
|
setTimeout(() => {
|
|
logout();
|
|
window.location.href = '/login';
|
|
}, 2000);
|
|
} catch (error: unknown) {
|
|
const apiError = parseApiError(error);
|
|
setMutationError(new Error(apiError.message));
|
|
} finally {
|
|
setIsDeletingAccount(false);
|
|
setIsDeleteDialogOpen(false);
|
|
}
|
|
}, [deletePassword, deleteReason, deleteConfirmText, toast, logout]);
|
|
|
|
const handleExportData = useCallback(async () => {
|
|
const performMutation = async () => {
|
|
const response = await apiClient.get('/users/me/export', {
|
|
responseType: 'blob',
|
|
});
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute(
|
|
'download',
|
|
`veza-data-export-${new Date().toISOString()}.json`,
|
|
);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
toast.success('Data export started');
|
|
setMutationError(null);
|
|
setRetryCount(0);
|
|
lastMutationRef.current = null;
|
|
};
|
|
|
|
lastMutationRef.current = performMutation;
|
|
try {
|
|
await performMutation();
|
|
} catch (error: unknown) {
|
|
const apiError = parseApiError(error);
|
|
setMutationError(new Error(apiError.message));
|
|
}
|
|
}, [toast]);
|
|
|
|
const handleRetry = useCallback(async () => {
|
|
if (!lastMutationRef.current || retryCount >= MAX_RETRY) return;
|
|
setRetryCount((prev) => prev + 1);
|
|
try {
|
|
await lastMutationRef.current();
|
|
} catch {
|
|
// Error handled by mutation
|
|
}
|
|
}, [retryCount]);
|
|
|
|
const dismissError = useCallback(() => {
|
|
setMutationError(null);
|
|
setRetryCount(0);
|
|
lastMutationRef.current = null;
|
|
}, []);
|
|
|
|
return {
|
|
mutationError,
|
|
retryCount,
|
|
maxRetry: MAX_RETRY,
|
|
handleRetry,
|
|
dismissError,
|
|
isChangingPassword,
|
|
currentPassword,
|
|
setCurrentPassword,
|
|
newPassword,
|
|
setNewPassword,
|
|
confirmPassword,
|
|
setConfirmPassword,
|
|
passwordError,
|
|
handleChangePassword,
|
|
isDeleteDialogOpen,
|
|
setIsDeleteDialogOpen,
|
|
isDeletingAccount,
|
|
deletePassword,
|
|
setDeletePassword,
|
|
deleteReason,
|
|
setDeleteReason,
|
|
deleteConfirmText,
|
|
setDeleteConfirmText,
|
|
deleteValidationError,
|
|
setDeleteValidationError,
|
|
handleDeleteAccount,
|
|
handleExportData,
|
|
};
|
|
}
|