import { useEffect, useState } from 'react'; import { Badge, Box, Button, Divider, Group, Paper, PasswordInput, SimpleGrid, Stack, Switch, Tabs, Text, TextInput, Title, UnstyledButton, useMantineColorScheme, type MantineColorScheme, } from '@mantine/core'; import { IconAt, IconBell, IconCheck, IconCircle, IconCircleCheckFilled, IconDeviceDesktop, IconDeviceFloppy, IconLayoutNavbar, IconLayoutSidebar, IconLock, IconMail, IconMoon, IconSettings, IconShieldLock, IconSun, IconUser, IconUserCircle, } from '@tabler/icons-react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; import { ActiveSessions, setUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { setLayoutMode } from '../../../store/preferences.slice'; import type { LayoutMode } from '../../../store/preferences.slice'; import classes from './ProfilePage.module.css'; function getInitials(name: string, fallback: string) { const source = name?.trim() || fallback?.trim() || ''; if (!source) return '?'; const parts = source.split(/\s+/); const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : source.slice(0, 2); return letters.toUpperCase(); } /** 0–4 rough strength score used by the meter on the security tab. */ function passwordScore(pw: string) { if (!pw) return 0; let score = 0; if (pw.length >= 8) score++; if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; if (/\d/.test(pw)) score++; if (/[^A-Za-z0-9]/.test(pw)) score++; return score; } export function ProfilePage() { const { t, i18n } = useTranslation(); const dispatch = useAppDispatch(); const user = useAppSelector((state) => state.auth.user); const { colorScheme, setColorScheme } = useMantineColorScheme(); const layoutMode = useAppSelector((state) => state.preferences.layoutMode); const { handleError } = useErrorHandler(); const [updateTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); const [passwordTrigger] = useApiMutation(); const [isSavingProfile, setIsSavingProfile] = useState(false); const [isSavingPassword, setIsSavingPassword] = useState(false); // UI-only preferences (no backend wiring yet). // Two-step verification is wired but parked for the testing phase: turning it // on makes every sign-in require an OTP. Swap this back for `useTwoFactor()` // to re-enable it (the login/OTP side already handles `mfaRequired`). const [twoStepEnabled, setTwoStepEnabled] = useState(false); // const { // enabled: twoStepEnabled, // isLoading: twoStepLoading, // isSaving: twoStepSaving, // setEnabled: setTwoStepEnabled, // } = useTwoFactor(); const [emailNotifications, setEmailNotifications] = useState(true); // Load the latest profile from the server on mount so the form always // reflects the current account information (the cached user may be stale). useEffect(() => { let active = true; meTrigger({ url: '/auth/me', method: 'GET' }) .unwrap() .then((me) => { if (active) dispatch(setUser(me)); }) .catch(() => { /* fall back to the cached user already in the store */ }); return () => { active = false; }; // meTrigger/dispatch are stable; run once on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ---- Profile form ---- const profileSchema = z.object({ nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }), nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }), username: z .string() .min(1, { message: t('profile.validation.usernameRequired') }), email: z.string().email({ message: t('profile.validation.emailInvalid') }), // Shared international rule: bare 09xxxxxxxx normalizes to +251, any // other E.164 number is accepted as typed. phoneNumber, }); type ProfileValues = z.infer; const { register: registerProfile, handleSubmit: handleProfileSubmit, reset: resetProfile, watch: watchProfile, setValue: setValueProfile, trigger: triggerProfile, formState: { errors: profileErrors }, } = useForm({ resolver: zodResolver(profileSchema), values: { nameEn: user?.name?.en ?? '', nameAm: user?.name?.am ?? '', username: user?.username ?? '', email: user?.email ?? '', phoneNumber: user?.phoneNumber ?? '', }, }); const onSaveProfile = async (values: ProfileValues) => { setIsSavingProfile(true); try { await updateTrigger({ url: '/auth/update-profile', method: 'PATCH', body: { email: values.email, username: values.username, phoneNumber: values.phoneNumber, name: { am: values.nameAm, en: values.nameEn }, }, }).unwrap(); const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap(); dispatch(setUser(me)); notify.success(t('profile.profileUpdated')); } catch (e) { handleError(e); } finally { setIsSavingProfile(false); } }; // ---- Password form ---- const passwordSchema = z .object({ oldPassword: z .string() .min(1, { message: t('profile.validation.passwordMin') }), newPassword: strongPasswordSchema(12), confirmPassword: z .string() .min(1, { message: t('profile.validation.passwordMin') }), }) .refine((data) => data.newPassword === data.confirmPassword, { message: t('profile.validation.passwordMismatch'), path: ['confirmPassword'], }); type PasswordValues = z.infer; const { register: registerPassword, handleSubmit: handlePasswordSubmit, reset: resetPassword, watch: watchPassword, formState: { errors: passwordErrors }, } = useForm({ resolver: zodResolver(passwordSchema), defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' }, }); const onChangePassword = async (values: PasswordValues) => { setIsSavingPassword(true); try { await passwordTrigger({ url: '/auth/change-password', method: 'PATCH', body: { oldPassword: values.oldPassword, newPassword: values.newPassword, confirmPassword: values.confirmPassword, }, }).unwrap(); notify.success(t('profile.passwordChanged')); resetPassword(); } catch (e) { handleError(e); } finally { setIsSavingPassword(false); } }; const displayName = user?.name?.en || user?.username || ''; const score = passwordScore(watchPassword('newPassword')); const strengthLabels = [ '', t('profile.strength.weak'), t('profile.strength.fair'), t('profile.strength.good'), t('profile.strength.strong'), ]; const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal']; const flags: Record = { en: '\uD83C\uDDEC\uD83C\uDDE7', am: '\uD83C\uDDEA\uD83C\uDDF9' }; const appearanceOptions: { value: MantineColorScheme; label: string; icon: typeof IconSun; }[] = [ { value: 'light', label: t('profile.appearance.light'), icon: IconSun }, { value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon }, { value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop }, ]; return ( {/* Profile summary */} {getInitials(displayName, user?.email ?? '')}
{displayName || '\u2014'} {user?.isPhoneNumberVerified ? t('profile.verified') : t('profile.unverified')} {user?.email}
{user?.username && ( } > {user.username} )}
{/* Tabs */} }> {t('profile.tabs.profile')} }> {t('profile.tabs.security')} }> {t('profile.tabs.preferences')} {/* ---- Profile ---- */}
{t('profile.personal')} {t('profile.personalHint')} } error={profileErrors.nameEn?.message} {...registerProfile('nameEn')} /> } error={profileErrors.nameAm?.message} {...registerProfile('nameAm')} /> } error={profileErrors.username?.message} {...registerProfile('username')} />
{t('profile.contact')} } error={profileErrors.email?.message} {...registerProfile('email')} /> setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })} onBlur={() => triggerProfile('phoneNumber')} error={profileErrors.phoneNumber?.message} />
{/* ---- Security ---- */}
{t('profile.security')} {t('profile.securityHint')} } error={passwordErrors.oldPassword?.message} {...registerPassword('oldPassword')} />
} error={passwordErrors.newPassword?.message} {...registerPassword('newPassword')} />
} error={passwordErrors.confirmPassword?.message} {...registerPassword('confirmPassword')} />
{score > 0 && ( {t('profile.strength.label')} {strengthLabels[score]} {[1, 2, 3, 4].map((i) => ( ))} )}
{t('profile.twoStep.title')} {t('profile.twoStep.desc')}
setTwoStepEnabled(e.currentTarget.checked)} // disabled={twoStepLoading || twoStepSaving} // onChange={async (e) => { // try { // await setTwoStepEnabled(e.currentTarget.checked); // notify.success(t('profile.twoStep.saved')); // } catch (err) { // handleError(err); // } // }} />
{/* ---- Preferences ---- */}
{t('profile.languageTitle')} {t('profile.languageHint')} {SUPPORTED_LANGUAGES.map((lng) => { const active = i18n.language === lng; return ( i18n.changeLanguage(lng)} className={`${classes.choice} ${active ? classes.choiceActive : ''}`} p="md" > {flags[lng as AppLanguage]}
{t(`language.${lng}`)} {lng === 'en' ? 'English (United States)' : 'Amharic'}
{active ? ( ) : ( )}
); })}
{t('profile.appearance.title')} {t('profile.appearance.subtitle')} {appearanceOptions.map(({ value, label, icon: Icon }) => { const active = colorScheme === value; return ( setColorScheme(value)} className={`${classes.choice} ${active ? classes.choiceActive : ''}`} p="md" > {label} {active && ( )} ); })}
{t('profile.layout.title')} {t('profile.layout.subtitle')} {([{ value: 'top', label: t('profile.layout.top'), icon: IconLayoutNavbar }, { value: 'sidebar', label: t('profile.layout.sidebar'), icon: IconLayoutSidebar }] as const).map(({ value, label, icon: Icon }) => { const active = layoutMode === value; return ( dispatch(setLayoutMode(value))} className={`${classes.choice} ${active ? classes.choiceActive : ''}`} p="md" > {label} {active && ( )} ); })}
{t('profile.notifications.title')} {t('profile.notifications.desc')}
setEmailNotifications(e.currentTarget.checked)} />
); }