Files
emaui/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx

680 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
/** 04 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<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>();
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<typeof profileSchema>;
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
watch: watchProfile,
setValue: setValueProfile,
trigger: triggerProfile,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
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<typeof passwordSchema>;
const {
register: registerPassword,
handleSubmit: handlePasswordSubmit,
reset: resetPassword,
watch: watchPassword,
formState: { errors: passwordErrors },
} = useForm<PasswordValues>({
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<AppLanguage, string> = { 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 (
<Stack gap="lg" maw={900}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} noMargin />
{/* Profile summary */}
<Paper p="lg" shadow="sm" radius="lg" withBorder>
<Group align="center" wrap="nowrap">
<Box
w={64}
h={64}
style={{
flexShrink: 0,
borderRadius: '50%',
backgroundImage: 'linear-gradient(135deg, #3b6ccc 0%, #1fc29d 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text fw={700} size="xl" c="white">
{getInitials(displayName, user?.email ?? '')}
</Text>
</Box>
<div>
<Group gap="xs" align="center">
<Title order={4}>{displayName || '\u2014'}</Title>
<Badge
variant="light"
color={user?.isPhoneNumberVerified ? 'emaTeal' : 'gray'}
size="sm"
>
{user?.isPhoneNumberVerified
? t('profile.verified')
: t('profile.unverified')}
</Badge>
</Group>
<Group gap={6} mt={2} c="dimmed">
<IconMail size={14} />
<Text size="sm" c="dimmed">
{user?.email}
</Text>
</Group>
</div>
<Box style={{ flex: 1 }} />
{user?.username && (
<Badge
visibleFrom="xs"
variant="default"
size="lg"
radius="xl"
leftSection={<IconAt size={13} />}
>
{user.username}
</Badge>
)}
</Group>
</Paper>
{/* Tabs */}
<Tabs
defaultValue="profile"
variant="pills"
classNames={{ list: classes.list, tab: classes.tab }}
>
<Tabs.List>
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')}
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
<Tabs.Tab value="preferences" leftSection={<IconSettings size={18} />}>
{t('profile.tabs.preferences')}
</Tabs.Tab>
</Tabs.List>
{/* ---- Profile ---- */}
<Tabs.Panel value="profile" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.personal')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.personalHint')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.fullNameEn')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameEn?.message}
{...registerProfile('nameEn')}
/>
<TextInput
label={t('profile.fields.fullNameAm')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameAm?.message}
{...registerProfile('nameAm')}
/>
<TextInput
label={t('profile.fields.username')}
description={t('profile.fields.usernameHint')}
readOnly
variant="filled"
leftSection={<IconAt size={18} />}
error={profileErrors.username?.message}
{...registerProfile('username')}
/>
</SimpleGrid>
</div>
<Divider />
<div>
<Title order={5} mb="md">
{t('profile.contact')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label={t('profile.fields.email')}
leftSection={<IconMail size={18} />}
error={profileErrors.email?.message}
{...registerProfile('email')}
/>
<PhoneInput
label={t('profile.fields.phone')}
value={watchProfile('phoneNumber') || ''}
onChange={(val) => setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })}
onBlur={() => triggerProfile('phoneNumber')}
error={profileErrors.phoneNumber?.message}
/>
</SimpleGrid>
</div>
<Group justify="flex-end">
<Button
type="button"
variant="default"
onClick={() => resetProfile()}
>
{t('profile.cancel')}
</Button>
<Button
type="submit"
loading={isSavingProfile}
leftSection={<IconDeviceFloppy size={18} />}
>
{t('profile.updateProfile')}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Stack gap="lg">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.security')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.securityHint')}
</Text>
<Stack gap="md">
<PasswordInput
maw={360}
label={t('profile.fields.currentPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.oldPassword?.message}
{...registerPassword('oldPassword')}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<div>
<PasswordInput
label={t('profile.fields.newPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.newPassword?.message}
{...registerPassword('newPassword')}
/>
<PasswordRequirements password={watchPassword('newPassword')} minLength={12} />
</div>
<PasswordInput
label={t('profile.fields.confirmPassword')}
leftSection={<IconLock size={18} />}
error={passwordErrors.confirmPassword?.message}
{...registerPassword('confirmPassword')}
/>
</SimpleGrid>
{score > 0 && (
<Stack gap={6}>
<Group justify="space-between">
<Text size="xs" c="dimmed" fw={600}>
{t('profile.strength.label')}
</Text>
<Text size="xs" fw={600} c={strengthColors[score]}>
{strengthLabels[score]}
</Text>
</Group>
<Group gap={6} grow>
{[1, 2, 3, 4].map((i) => (
<Box
key={i}
h={6}
style={{
borderRadius: 999,
backgroundColor:
i <= score
? `var(--mantine-color-${strengthColors[score]}-6)`
: 'var(--mantine-color-default-border)',
}}
/>
))}
</Group>
</Stack>
)}
</Stack>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{t('profile.twoStep.title')}</Text>
<Text size="sm" c="dimmed">
{t('profile.twoStep.desc')}
</Text>
</div>
<Switch
checked={twoStepEnabled}
onChange={(e) => 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);
// }
// }}
/>
</Group>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingPassword}
leftSection={<IconShieldLock size={18} />}
>
{t('profile.updatePassword')}
</Button>
</Group>
</Stack>
</form>
</Paper>
<ActiveSessions />
</Stack>
</Tabs.Panel>
{/* ---- Preferences ---- */}
<Tabs.Panel value="preferences" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.languageTitle')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.languageHint')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{SUPPORTED_LANGUAGES.map((lng) => {
const active = i18n.language === lng;
return (
<UnstyledButton
key={lng}
onClick={() => i18n.changeLanguage(lng)}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Text fz={22}>{flags[lng as AppLanguage]}</Text>
<div style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t(`language.${lng}`)}
</Text>
<Text size="xs" c="dimmed">
{lng === 'en' ? 'English (United States)' : 'Amharic'}
</Text>
</div>
{active ? (
<IconCircleCheckFilled
size={20}
color="var(--mantine-color-emaPrimary-6)"
/>
) : (
<IconCircle
size={20}
color="var(--mantine-color-dimmed)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<div>
<Title order={5}>{t('profile.appearance.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.appearance.subtitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{appearanceOptions.map(({ value, label, icon: Icon }) => {
const active = colorScheme === value;
return (
<UnstyledButton
key={value}
onClick={() => setColorScheme(value)}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Icon
size={20}
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-dimmed)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
{label}
</Text>
{active && (
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-emaPrimary-6)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<div>
<Title order={5}>{t('profile.layout.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.layout.subtitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{([{ 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 (
<UnstyledButton
key={value}
onClick={() => dispatch(setLayoutMode(value))}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Icon
size={20}
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-dimmed)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
{label}
</Text>
{active && (
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-emaPrimary-6)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<IconBell size={20} color="var(--mantine-color-dimmed)" />
<div>
<Text fw={600}>{t('profile.notifications.title')}</Text>
<Text size="sm" c="dimmed">
{t('profile.notifications.desc')}
</Text>
</div>
</Group>
<Switch
checked={emailNotifications}
onChange={(e) => setEmailNotifications(e.currentTarget.checked)}
/>
</Group>
<Group justify="flex-end">
<Button
leftSection={<IconCheck size={18} />}
onClick={() => notify.success(t('profile.profileUpdated'))}
>
{t('profile.savePreferences')}
</Button>
</Group>
</Stack>
</Paper>
</Tabs.Panel>
</Tabs>
</Stack>
);
}