From 7de485ce283335dd72f84de148309481cbbfc50d Mon Sep 17 00:00:00 2001 From: Mengisteab Date: Thu, 11 Jun 2026 12:04:21 +0000 Subject: [PATCH] update user profile page --- .claude/settings.json | 8 +- .../profile/pages/ProfilePage.module.css | 56 ++ .../features/profile/pages/ProfilePage.tsx | 555 +++++++++++++----- apps/portal/src/app/i18n/locales/am.ts | 34 ++ apps/portal/src/app/i18n/locales/en.ts | 34 ++ ppencil-welcome.pen => portal.pen | 0 6 files changed, 554 insertions(+), 133 deletions(-) create mode 100644 apps/portal/src/app/features/profile/pages/ProfilePage.module.css rename ppencil-welcome.pen => portal.pen (100%) diff --git a/.claude/settings.json b/.claude/settings.json index c8f10648e..499ac81dc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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'\\)}}\\)\")" ] } } diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.module.css b/apps/portal/src/app/features/profile/pages/ProfilePage.module.css new file mode 100644 index 000000000..3c37f92a1 --- /dev/null +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.module.css @@ -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); +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index d3955d4e7..3e8c35961 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -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(); } +/** 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 [updateTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); @@ -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({ resolver: zodResolver(profileSchema), @@ -131,6 +183,7 @@ export function ProfilePage() { register: registerPassword, handleSubmit: handlePasswordSubmit, reset: resetPassword, + watch: watchPassword, formState: { errors: passwordErrors }, } = useForm({ 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 = { 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 ( - + - {/* Profile details */} - - - - {getInitials(displayName, user?.email ?? '')} - + {/* Profile summary */} + + + + + {getInitials(displayName, user?.email ?? '')} + + +
{displayName || 'โ€”'} {user?.isPhoneNumberVerified @@ -184,134 +272,337 @@ export function ProfilePage() { : t('profile.unverified')} - - {user?.email} - + + + + {user?.email} + +
+ + + + {user?.username && ( + } + > + {user.username} + + )}
- -
- -
- - {t('profile.personal')} - - - } - error={profileErrors.nameEn?.message} - {...registerProfile('nameEn')} - /> - } - error={profileErrors.nameAm?.message} - {...registerProfile('nameAm')} - /> - - -
- -
- - {t('profile.contact')} - - - } - error={profileErrors.email?.message} - {...registerProfile('email')} - /> - } - error={profileErrors.phoneNumber?.message} - {...registerProfile('phoneNumber')} - /> - -
- - - - -
-
- {/* Change password */} - - {t('profile.security')} - - {t('profile.securityHint')} - - + {/* Tabs */} + + + }> + {t('profile.tabs.profile')} + + }> + {t('profile.tabs.security')} + + }> + {t('profile.tabs.preferences')} + + -
- - } - error={passwordErrors.oldPassword?.message} - {...registerPassword('oldPassword')} - /> - - } - error={passwordErrors.newPassword?.message} - {...registerPassword('newPassword')} - /> - } - error={passwordErrors.confirmPassword?.message} - {...registerPassword('confirmPassword')} - /> - + {/* ---- 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')} + /> + +
- - - -
- -
+ - {/* Preferences */} - - - {t('profile.preferences')} - -