update user profile page

This commit is contained in:
Mengisteab
2026-06-11 12:04:21 +00:00
parent 0ce32f827a
commit 7de485ce28
6 changed files with 554 additions and 133 deletions

View File

@@ -9,7 +9,13 @@
"Bash(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.json)",
"Bash(node_modules/.bin/tsc --noEmit -p tsconfig.json)",
"Bash(../../node_modules/.bin/tsc --noEmit -p tsconfig.json)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
"Bash(code /home/tria/projects/mengisteab/emaui/ema-portal.pen)",
"Bash(awk '/profile: \\\\{/,/^ \\\\},/' src/app/i18n/locales/en.ts)",
"Bash(awk '/profile: \\\\{/,/^ \\\\},/' src/app/i18n/locales/am.ts)",
"Bash(echo \"=== total errors: $\\(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.json 2>&1)",
"Bash(node -e \"const p=require\\('./package.json'\\); console.log\\(JSON.stringify\\(p.scripts,null,2\\)\\)\")",
"Bash(node -e \"let s='';process.stdin.on\\('data',d=>s+=d\\).on\\('end',\\(\\)=>{try{console.log\\(Object.keys\\(JSON.parse\\(s\\).targets||{}\\)\\)}catch\\(e\\){console.log\\('no project.json'\\)}}\\)\")"
]
}
}

View File

@@ -0,0 +1,56 @@
/* Segmented "pill" tab bar — light gray track with a white active pill. */
.list {
display: inline-flex;
gap: 6px;
padding: 5px;
background: var(--mantine-color-gray-1);
border-radius: var(--mantine-radius-md);
border: none;
flex-wrap: wrap;
}
.tab {
border: none;
border-radius: 10px;
padding: 9px 18px;
font-weight: 500;
color: var(--mantine-color-gray-7);
background: transparent;
transition:
background-color 120ms ease,
color 120ms ease,
box-shadow 120ms ease;
}
.tab:hover {
background: transparent;
color: var(--mantine-color-gray-9);
}
.tab[data-active],
.tab[data-active]:hover {
background: var(--mantine-color-body);
color: var(--mantine-color-emaPrimary-7);
font-weight: 600;
box-shadow: var(--mantine-shadow-xs);
}
/* Selectable option card (language + appearance). */
.choice {
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 120ms ease,
background-color 120ms ease;
}
.choice:hover {
border-color: var(--mantine-color-gray-4);
}
.choiceActive,
.choiceActive:hover {
border-color: var(--mantine-color-emaPrimary-6);
background: var(--mantine-color-emaPrimary-0);
}

View File

@@ -1,25 +1,40 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Avatar,
Badge,
Box,
Button,
Divider,
Group,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack,
Switch,
Tabs,
Text,
TextInput,
Title,
UnstyledButton,
useMantineColorScheme,
type MantineColorScheme,
} from '@mantine/core';
import {
IconAt,
IconBell,
IconCheck,
IconCircle,
IconCircleCheckFilled,
IconDeviceDesktop,
IconDeviceFloppy,
IconLock,
IconMail,
IconMoon,
IconPhone,
IconSettings,
IconShieldLock,
IconSun,
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -32,6 +47,7 @@ import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setUser } from '../../auth/store/auth.slice';
import type { AuthUser } from '../../auth/types/auth.types';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
const source = name?.trim() || fallback?.trim() || '';
@@ -41,10 +57,22 @@ function getInitials(name: string, fallback: string) {
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 [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
@@ -53,6 +81,29 @@ export function ProfilePage() {
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
// UI-only preferences (no backend wiring yet).
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
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') }),
@@ -70,6 +121,7 @@ export function ProfilePage() {
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
@@ -131,6 +183,7 @@ export function ProfilePage() {
register: registerPassword,
handleSubmit: handlePasswordSubmit,
reset: resetPassword,
watch: watchPassword,
formState: { errors: passwordErrors },
} = useForm<PasswordValues>({
resolver: zodResolver(passwordSchema),
@@ -160,23 +213,58 @@ export function ProfilePage() {
};
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: '🇬🇧', am: '🇪🇹' };
// Mantine uses 'auto' for the system option.
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={820}>
<Stack gap="lg" maw={900}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
{/* Profile details */}
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Group mb="lg" align="center">
<Avatar color="emaPrimary" radius="xl" size={64}>
{/* 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 ?? '')}
</Avatar>
</Text>
</Box>
<div>
<Group gap="xs" align="center">
<Title order={4}>{displayName || '—'}</Title>
<Badge
variant="light"
color={user?.isPhoneNumberVerified ? 'teal' : 'gray'}
color={user?.isPhoneNumberVerified ? 'emaTeal' : 'gray'}
size="sm"
>
{user?.isPhoneNumberVerified
@@ -184,18 +272,58 @@ export function ProfilePage() {
: t('profile.unverified')}
</Badge>
</Group>
<Group gap={6} mt={2} c="dimmed">
<IconMail size={14} />
<Text size="sm" c="dimmed">
{user?.email}
</Text>
</div>
</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="lg">
<Stack gap="xl">
<div>
<Title order={5} mb="sm">
{t('profile.personal')}
</Title>
<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')}
@@ -211,14 +339,20 @@ export function ProfilePage() {
/>
<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="sm">
<Title order={5} mb="md">
{t('profile.contact')}
</Title>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
@@ -237,7 +371,14 @@ export function ProfilePage() {
</SimpleGrid>
</div>
<Group>
<Group justify="flex-end">
<Button
type="button"
variant="default"
onClick={() => resetProfile()}
>
{t('profile.cancel')}
</Button>
<Button
type="submit"
loading={isSavingProfile}
@@ -249,16 +390,18 @@ export function ProfilePage() {
</Stack>
</form>
</Paper>
</Tabs.Panel>
{/* Change password */}
<Paper p="lg" shadow="sm" radius="md" withBorder>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<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>
<Divider mb="md" />
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="md">
<PasswordInput
maw={360}
@@ -282,36 +425,184 @@ export function ProfilePage() {
/>
</SimpleGrid>
<Group>
{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-gray-2)',
}}
/>
))}
</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)}
/>
</Group>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingPassword}
leftSection={<IconLock size={18} />}
leftSection={<IconShieldLock size={18} />}
>
{t('profile.changePassword')}
{t('profile.updatePassword')}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Tabs.Panel>
{/* Preferences */}
<Paper p="lg" shadow="sm" radius="md" withBorder>
<Title order={5} mb="sm">
{t('profile.preferences')}
</Title>
<Select
maw={320}
label={t('profile.fields.language')}
data={SUPPORTED_LANGUAGES.map((lng) => ({
value: lng,
label: t(`language.${lng}`),
}))}
value={i18n.language}
onChange={(v) => v && i18n.changeLanguage(v as AppLanguage)}
allowDeselect={false}
{/* ---- 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]}</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-gray-4)"
/>
)}
</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-gray-6)'
}
/>
<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-gray-6)" />
<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>
);
}

View File

@@ -232,9 +232,42 @@ export const am: Translations = {
security: 'ደህንነት',
securityHint: 'በሌላ ቦታ የማይጠቀሙበትን ጠንካራ የይለፍ ቃል ይምረጡ።',
changePassword: 'የይለፍ ቃል ይቀይሩ',
updatePassword: 'የይለፍ ቃል አዘምን',
updateProfile: 'ለውጦችን አስቀምጥ',
savePreferences: 'ምርጫዎችን አስቀምጥ',
cancel: 'ሰርዝ',
verified: 'ተረጋግጧል',
unverified: 'አልተረጋገጠም',
tabs: {
profile: 'መገለጫ',
security: 'ደህንነት',
preferences: 'ምርጫዎች',
},
personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።',
languageTitle: 'ቋንቋ',
languageHint: 'በ EMA ፖርታል ላይ የሚጠቀሙበትን ቋንቋ ይምረጡ።',
appearance: {
title: 'መልክ',
subtitle: 'ፖርታሉ በመሣሪያዎ ላይ እንዴት እንደሚታይ ይምረጡ።',
light: 'ብርሃን',
dark: 'ጨለማ',
system: 'ሲስተም',
},
twoStep: {
title: 'ባለ ሁለት ደረጃ ማረጋገጫ',
desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',
},
notifications: {
title: 'የኢሜይል ማሳወቂያዎች',
desc: 'ስለ ፈቃድ ማመልከቻዎችዎና የመለያ እንቅስቃሴ በኢሜይል ዝማኔዎችን ይቀበሉ።',
},
strength: {
label: 'የይለፍ ቃል ጥንካሬ',
weak: 'ደካማ',
fair: 'መካከለኛ',
good: 'ጥሩ',
strong: 'ጠንካራ',
},
profileUpdated: 'መገለጫ በተሳካ ሁኔታ ተዘምኗል',
passwordChanged: 'የይለፍ ቃል በተሳካ ሁኔታ ተቀይሯል',
updateFailed: 'መገለጫን ማዘመን አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
@@ -244,6 +277,7 @@ export const am: Translations = {
fullNameEn: 'ሙሉ ስም (እንግሊዝኛ)',
fullNameAm: 'ሙሉ ስም (አማርኛ)',
username: 'የተጠቃሚ ስም',
usernameHint: 'የተጠቃሚ ስም መቀየር አይቻልም',
organization: 'ድርጅት',
email: 'የኢሜይል አድራሻ',
phone: 'ስልክ ቁጥር',

View File

@@ -231,9 +231,42 @@ export const en = {
security: 'Security',
securityHint: 'Choose a strong password you do not use anywhere else.',
changePassword: 'Change password',
updatePassword: 'Update password',
updateProfile: 'Save changes',
savePreferences: 'Save preferences',
cancel: 'Cancel',
verified: 'Verified',
unverified: 'Unverified',
tabs: {
profile: 'Profile',
security: 'Security',
preferences: 'Preferences',
},
personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language',
languageHint: 'Choose the language used across the EMA portal.',
appearance: {
title: 'Appearance',
subtitle: 'Select how the portal looks on your device.',
light: 'Light',
dark: 'Dark',
system: 'System',
},
twoStep: {
title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.',
},
notifications: {
title: 'Email notifications',
desc: 'Receive updates about your license applications and account activity by email.',
},
strength: {
label: 'Password strength',
weak: 'Weak',
fair: 'Fair',
good: 'Good',
strong: 'Strong',
},
profileUpdated: 'Profile updated successfully',
passwordChanged: 'Password changed successfully',
updateFailed: 'Could not update profile. Please try again.',
@@ -243,6 +276,7 @@ export const en = {
fullNameEn: 'Full name (English)',
fullNameAm: 'Full name (Amharic)',
username: 'Username',
usernameHint: 'Username cannot be changed',
organization: 'Organization',
email: 'Email address',
phone: 'Phone number',