chore: move api to api folder and seperate the types into another file

This commit is contained in:
Koosha Lahouti
2025-08-15 15:21:34 +03:30
parent 5a72b597c8
commit 0b42f4ccba
22 changed files with 845 additions and 1402 deletions

View File

@@ -1,22 +1,23 @@
import axios, { isAxiosError } from 'axios';
import apiClient from '@/lib/apiClient';
import {
type GetProfileApiResponse,
type SaveProfileApiResponse,
type TokenApiResponse,
type PhoneNumberApiResponse,
type ConfirmPhoneNumberApiResponse,
type SaveSettingsApiResponse,
type DeleteSessionsApiResponse,
type PasswordApiResponse,
type SendEmailCodeApiResponse,
type ConfirmEmailCodeApiResponse,
type ChangeEmailApiResponse,
} from '../types/settingsApiType';
import { type InfoRowData } from '../types/settingsType';
const storedToken = () => localStorage.getItem('authToken');
export async function fetchProfile(): Promise<{ data: GetProfileApiResponse }> {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${storedToken()}` } },
);
return { data: res.data };
}
@@ -28,56 +29,13 @@ export async function saveProfile(payload?: {
if (!payload) {
throw new Error('Payload for saving profile is missing.');
}
const res = await apiClient.post<SaveProfileApiResponse>(
'/Profile/SaveProfilePersonalInforamtion',
payload,
{ headers: { Authorization: `Bearer ${storedToken()}` } },
);
return { data: res.data };
}
export async function getToken(): Promise<{
data: { success: boolean; message?: string };
}> {
const apiUrl = 'https://accounts.business-harmony.com';
const tokenEndpoint = `${apiUrl}/connect/token`;
try {
const body = new URLSearchParams();
body.set('grant_type', 'password');
body.set('username', 'zareian.1381@gmail.com');
body.set('password', '123@Qweasd');
body.set('client_id', 'harmony_identity');
body.set('scope', 'openid harmony_identity profile offline_access');
const response = await axios.post<TokenApiResponse>(
tokenEndpoint,
body.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if (response.data?.access_token) {
localStorage.setItem('authToken', response.data.access_token);
return { data: { success: true } };
} else {
throw new Error('No access token in response');
}
} catch (error: unknown) {
let message = 'Failed to get token';
if (isAxiosError(error) && error.response) {
message = `Request failed with status ${error.response.status}`;
} else if (error instanceof Error) {
message = error.message;
}
throw new Error(message);
}
}
export async function sendVerificationCode(payload?: {
phoneNumber: string;
}): Promise<{ data: PhoneNumberApiResponse }> {
@@ -87,7 +45,6 @@ export async function sendVerificationCode(payload?: {
const res = await apiClient.post<PhoneNumberApiResponse>(
'/Profile/SendVerfiyPhoneNumberCode',
payload,
{ headers: { Authorization: `Bearer ${storedToken()}` } },
);
return { data: res.data };
}
@@ -102,7 +59,6 @@ export async function confirmPhoneNumberCode(payload?: {
const res = await apiClient.post<ConfirmPhoneNumberApiResponse>(
'/Profile/ConfirmPhoneNumberChangeCode',
payload,
{ headers: { Authorization: `Bearer ${storedToken()}` } },
);
return { data: res.data };
}
@@ -116,7 +72,102 @@ export async function changePhoneNumber(payload?: {
const res = await apiClient.post<PhoneNumberApiResponse>(
'/Profile/ChangePhoneNumber',
payload,
{ headers: { Authorization: `Bearer ${storedToken()}` } },
);
return { data: res.data };
}
export async function saveSettings(payload?: {
theme: number;
calendarType: number;
language: number;
}): Promise<{ data: SaveSettingsApiResponse }> {
if (!payload) {
throw new Error('Payload for saving settings is missing.');
}
const res = await apiClient.post<SaveSettingsApiResponse>(
'/Profile/SaveSetting',
payload,
);
return { data: res.data };
}
export async function deleteSessions(payload?: {
keys: string[];
}): Promise<{ data: DeleteSessionsApiResponse }> {
if (!payload || payload.keys.length === 0) {
throw new Error('Payload with session keys is missing or empty.');
}
const res = await apiClient.post<DeleteSessionsApiResponse>(
'/Profile/DeleteSessions',
payload,
);
return { data: res.data };
}
export async function addPassword(payload?: {
password: string;
}): Promise<{ data: PasswordApiResponse }> {
if (!payload) {
throw new Error('Payload for adding password is missing.');
}
const res = await apiClient.post<PasswordApiResponse>(
'/Profile/AddPassword',
payload,
);
return { data: res.data };
}
export async function changePassword(payload?: {
oldPassword: string;
newPassword: string;
}): Promise<{ data: PasswordApiResponse }> {
if (!payload) {
throw new Error('Payload for changing password is missing.');
}
const res = await apiClient.post<PasswordApiResponse>(
'/Profile/ChangePassword',
payload,
);
return { data: res.data };
}
// ✅ NEW FUNCTIONS
export async function sendEmailCode(payload?: {
email: string;
}): Promise<{ data: SendEmailCodeApiResponse }> {
if (!payload) {
throw new Error('Payload for sending email code is missing.');
}
const res = await apiClient.post<SendEmailCodeApiResponse>(
'Profile/SendEmailChangeCode',
payload,
);
return { data: res.data };
}
export async function confirmEmailCode(payload?: {
email: string;
verifyCode: string;
}): Promise<{ data: ConfirmEmailCodeApiResponse }> {
if (!payload) {
throw new Error('Payload for confirming email code is missing.');
}
const res = await apiClient.post<ConfirmEmailCodeApiResponse>(
'Profile/ConfirmEmailChangeCode',
payload,
);
return { data: res.data };
}
export async function changeEmail(payload?: {
email: string;
}): Promise<{ data: ChangeEmailApiResponse }> {
if (!payload) {
throw new Error('Payload for changing email is missing.');
}
const res = await apiClient.post<ChangeEmailApiResponse>(
'Profile/ChangeEmail',
payload,
);
return { data: res.data };
}

View File

@@ -8,173 +8,72 @@ import {
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { DeviceMessage, Logout } from 'iconsax-react';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import apiClient from '@/lib/apiClient';
import { toLocaleDigits } from '@/utils/persianDigit';
function formatSessionDate(
isoDate: string,
lang: string,
t: TFunction,
): string {
const date = new Date(isoDate);
const now = new Date();
const diffInMinutes = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60),
);
if (diffInMinutes < 1) {
return t('active.justNow');
}
if (diffInMinutes < 60) {
const minuteString = t('active.minutesAgo', { count: diffInMinutes });
return toLocaleDigits(minuteString, lang);
}
let displayLocale: string;
let options: Intl.DateTimeFormatOptions;
if (lang.startsWith('fa')) {
displayLocale = 'fa-IR';
options = {
calendar: 'persian',
numberingSystem: 'arab',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
} else {
displayLocale = 'en-US';
options = {
calendar: 'gregory',
numberingSystem: 'latn',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
}
const formattedDate = new Intl.DateTimeFormat(displayLocale, options).format(
date,
);
return toLocaleDigits(formattedDate, lang);
}
interface Device {
id: string;
timeAndDate: string;
deviceModel: string;
ip: string;
current: boolean;
}
interface ApiSession {
key: string;
created: string;
deviceOs: string;
deviceName: string;
ipAddress: string;
}
interface ActiveSessionsData {
sessions: ApiSession[];
currentKey: string;
}
interface ProfileApiResponse {
success: boolean;
message?: string;
activeSessions?: ActiveSessionsData;
}
import { fetchProfile, deleteSessions } from '../../api/settingsApi';
import { type ApiSession } from '../../types/settingsApiType';
import { useManualApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate';
import { type Device } from '../../types/settingsType';
export function ActiveDevices() {
const { t, i18n } = useTranslation('setting');
const token = localStorage.getItem('authToken');
const [devices, setDevices] = useState<Device[]>([]);
const [loadingDeleteIds, setLoadingDeleteIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const theme = useTheme();
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: terminateData,
loading: isTerminating,
execute: executeTerminateAll,
} = useManualApi(deleteSessions);
useEffect(() => {
const fetchActiveSessions = async () => {
setIsLoading(true);
setFetchError(null);
if (!token) {
setIsLoading(false);
setFetchError(t('active.notLoggedIn'));
return;
}
try {
const res = await apiClient.post<ProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
executeFetchProfile();
}, [i18n.language]);
if (res.data.success && res.data.activeSessions) {
const { sessions, currentKey } = res.data.activeSessions;
const formattedDevices = sessions.map((session: ApiSession) => ({
id: session.key,
timeAndDate: formatSessionDate(session.created, i18n.language, t),
deviceModel: `${session.deviceOs} ${session.deviceName}`,
ip: session.ipAddress,
current: session.key === currentKey,
}));
setDevices(formattedDevices);
} else {
throw new Error(
res.data.message || t('active.failFetchActiveSessions'),
);
}
} catch (err: unknown) {
console.error(t('active.failFetchActiveSessions'), err);
let message = t('active.errorFetch');
if (err instanceof Error) {
message = err.message;
}
setFetchError(message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (profileData?.success && profileData.activeSessions) {
const { sessions, currentKey } = profileData.activeSessions;
const formattedDevices = sessions.map((session: ApiSession) => ({
id: session.key,
timeAndDate: formatDate(session.created, i18n.language, t),
deviceModel: `${session.deviceOs} ${session.deviceName}`,
ip: session.ipAddress,
current: session.key === currentKey,
}));
setDevices(formattedDevices);
}
}, [profileData, i18n.language, t]);
fetchActiveSessions();
}, [token, i18n.language, t]);
useEffect(() => {
if (terminateData?.success) {
setDevices((prev) => prev.filter((d) => d.current));
}
}, [terminateData]);
const handleDeleteDevice = async (id: string) => {
if (loadingDeleteIds.includes(id)) return;
setLoadingDeleteIds((prev) => [...prev, id]);
try {
const res = await apiClient.post(
'/Profile/DeleteSessions',
{
keys: [id],
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
const { data } = await deleteSessions({ keys: [id] });
if (data.success) {
setDevices((prevDevices) => prevDevices.filter((d) => d.id !== id));
} else {
console.error('Delete failed:', res.data.message);
console.error('Delete failed:', data.message);
}
} catch (error: unknown) {
// console.error('Delete error:', error);
console.error('Delete error:', error);
} finally {
setLoadingDeleteIds((prev) =>
prev.filter((loadingId) => loadingId !== id),
@@ -182,30 +81,19 @@ export function ActiveDevices() {
}
};
const handleTerminateAllOtherSessions = async () => {
const handleTerminateAllOtherSessions = () => {
const otherSessionKeys = devices.filter((d) => !d.current).map((d) => d.id);
if (otherSessionKeys.length === 0) return;
try {
const res = await apiClient.post(
'/Profile/DeleteSessions',
{
keys: otherSessionKeys,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setDevices((prev) => prev.filter((d) => d.current));
} else {
console.error('Failed to terminate other sessions:', res.data.message);
}
} catch (error: unknown) {
console.error('Error terminating sessions:', error);
if (otherSessionKeys.length > 0) {
executeTerminateAll({ keys: otherSessionKeys });
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<CardContainer
@@ -222,9 +110,11 @@ export function ActiveDevices() {
color: 'error.main',
textTransform: 'none',
}}
disabled={isLoading}
disabled={isLoading || isTerminating}
>
{t('active.deleteDevicesButton')}
{isTerminating
? t('active.deleting')
: t('active.deleteDevicesButton')}
</Button>
}
>
@@ -242,7 +132,9 @@ export function ActiveDevices() {
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">{fetchError}</Typography>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box
@@ -352,13 +244,7 @@ export function ActiveDevices() {
<Button
size="small"
variant="outlined"
startIcon={
<Icon
Component={Logout}
size="small"
color="error.main"
/>
}
startIcon={<Icon Component={Logout} size="small" />}
disabled={
device.current || loadingDeleteIds.includes(device.id)
}
@@ -378,14 +264,16 @@ export function ActiveDevices() {
},
}}
>
{loadingDeleteIds.includes(device.id)
? t('active.deleting')
: t('active.deleteDevice')}
{loadingDeleteIds.includes(device.id) ? (
<CircularProgress size={20} color="error" />
) : (
t('active.deleteDevice')
)}
</Button>
</Box>
</Box>
{isXsup && (
<Box sx={{ color: 'divider', borderBottom: '1px solid' }} />
<Box sx={{ borderBottom: 1, borderColor: 'divider' }} />
)}
</React.Fragment>
))}

View File

@@ -1,25 +0,0 @@
import { Box, Typography } from '@mui/material';
export function Header() {
const headers = [{ name: 'محمدحسین برزه گر', phone: '09123456789' }];
return (
<Box
sx={{
height: 84,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
px: 2,
}}
>
{headers.map((h) => (
<Box key={h.phone}>
<Typography variant="body2">{h.name}</Typography>
<Typography variant="body2" color="text.secondary">
{h.phone}
</Typography>
</Box>
))}
</Box>
);
}

View File

@@ -1,127 +0,0 @@
import { useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import {
Box,
useTheme,
useMediaQuery,
Drawer,
AppBar,
Toolbar,
IconButton,
} from '@mui/material';
import { SideNav } from '@rkheftan/harmony-ui';
import { useTranslation } from 'react-i18next';
import { HambergerMenu } from 'iconsax-react';
import { Header } from './Header';
import { getNavConfig } from './navConfigs';
export function Layout() {
const theme = useTheme();
const isMdUp = useMediaQuery(theme.breakpoints.up('md'));
const isLgUp = useMediaQuery(theme.breakpoints.up('lg'));
const location = useLocation();
const { t } = useTranslation('sideMap');
const [drawerOpen, setDrawerOpen] = useState(false);
const navWidthMd = isLgUp ? 274 : 'min(15vw, 180px)';
// const sidebarWidth = isLgUp ? 274 : isMdUp ? 'min(15vw, 180px)' : 0;
const navConfig = getNavConfig(t);
return (
<Box
sx={{
minHeight: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
px: { xs: 0, sm: 0, md: 'max(1vw, 8px)', lg: '60px' },
py: { xs: 0, sm: 0, md: '40px', lg: '80px' },
boxSizing: 'border-box',
backgroundColor: 'background.default',
}}
>
<Box
sx={{
width: {
xs: '100vw',
md: 'min(90vw, 900px)',
lg: '1100px',
},
height: '100vh',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.paper',
overflow: 'hidden',
borderRadius: { xs: 0, md: 2 },
boxShadow: { xs: 0, md: 3 },
}}
>
{!isMdUp && (
<AppBar position="static" elevation={0}>
<Toolbar sx={{ backgroundColor: 'background.paper', gap: 2 }}>
<IconButton
edge="start"
onClick={() => setDrawerOpen(true)}
size="medium"
sx={{ backgroundColor: '#2979FF1F' }}
>
<HambergerMenu size={24} color="#1976d2" />
</IconButton>
</Toolbar>
</AppBar>
)}
<Box sx={{ flexGrow: 1, display: 'flex', overflow: 'hidden' }}>
{isMdUp && (
<Box
sx={{
width: navWidthMd,
flexShrink: 0,
transition: 'width 0.3s ease',
}}
>
<SideNav
navConfig={navConfig}
header={<Header />}
activePath={location.pathname + location.hash}
sideNavVariant="full"
positioning="absolute"
/>
</Box>
)}
<Box
sx={{
flexGrow: 1,
overflowY: 'auto',
px: { xs: 2, sm: 3, md: 'max(1.5vw, 12px)' },
py: { xs: 2, sm: 3 },
}}
>
<Outlet />
</Box>
</Box>
{!isMdUp && (
<Drawer
anchor="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Box width={navWidthMd}>
<SideNav
navConfig={navConfig}
header={<Header />}
activePath={location.pathname + location.hash}
sideNavVariant="full"
/>
</Box>
</Drawer>
)}
</Box>
</Box>
);
}

View File

@@ -1,52 +0,0 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './Layout';
import { PersonalInformation } from '../userInformation/PersonalInformation';
import { PhoneNumber } from '../userInformation/PhoneNumber';
import { SocialMedia } from '../userInformation/SocialMedia';
import { PasswordSecurity } from '../security/PasswordSecurity';
import { RecentLogins } from '../security/RecentLogins';
import { ActiveDevices } from '../activeDevices/ActiveDevices';
import { Setting } from '../setting/Setting';
import { Box } from '@mui/material';
// tooye route ye component taarif mikonim be esme profile bad mibarim tooye route
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ path: '/', element: <Navigate to="/profile" replace /> },
{
path: '/profile',
element: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div id="info">
<PersonalInformation />
</div>
<div id="contact-info">
<PhoneNumber />
</div>
<div id="email">
<SocialMedia />
</div>
</Box>
),
},
{
path: '/security',
element: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div id="password">
<PasswordSecurity />
</div>
<div id="locations"></div>
<div id="sessions">
<RecentLogins />
</div>
</Box>
),
},
{ path: '/devices', element: <ActiveDevices /> },
{ path: '/setting', element: <Setting /> },
],
},
]);

View File

@@ -1,121 +0,0 @@
import {
Devices,
LocationTick,
Mobile,
PasswordCheck,
Personalcard,
ProfileCircle,
Setting as SettingIcon,
Shield,
Sms,
} from 'iconsax-react';
import { type TFunction } from 'i18next';
export function getNavConfig(t: TFunction) {
return [
{
text: t('side.account'),
getIcon: (selected: boolean) =>
selected ? (
<ProfileCircle size={24} color="#1976d2" variant="Bold" />
) : (
<ProfileCircle size={24} color="#82B1FF" />
),
path: '/profile',
children: [
{
text: t('side.personalInfo'),
getIcon: (sel: boolean) =>
sel ? (
<Personalcard size={24} color="#1976d2" variant="Bold" />
) : (
<Personalcard size={24} color="#82B1FF" />
),
path: '/profile#info',
},
{
text: t('side.phoneNumber'),
getIcon: (sel: boolean) =>
sel ? (
<Mobile size={24} color="#1976d2" variant="Bold" />
) : (
<Mobile size={24} color="#82B1FF" />
),
path: '/profile#contact-info',
},
{
text: t('side.email'),
getIcon: (sel: boolean) =>
sel ? (
<Sms size={24} color="#1976d2" variant="Bold" />
) : (
<Sms size={24} color="#82B1FF" />
),
path: '/profile#email',
},
],
},
{
text: t('side.security'),
getIcon: (sel: boolean) =>
sel ? (
<Shield size={24} color="#1976d2" variant="Bold" />
) : (
<Shield size={24} color="#82B1FF" />
),
path: '/security',
children: [
{
text: t('side.password'),
getIcon: (sel: boolean) =>
sel ? (
<PasswordCheck size={24} color="#1976d2" variant="Bold" />
) : (
<PasswordCheck size={24} color="#82B1FF" />
),
path: '/security#password',
},
{
text: t('side.verifiedAddress'),
getIcon: (sel: boolean) =>
sel ? (
<LocationTick size={24} color="#1976d2" variant="Bold" />
) : (
<LocationTick size={24} color="#82B1FF" />
),
path: '/security#locations',
},
{
text: t('side.recentLogins'),
getIcon: (sel: boolean) =>
sel ? (
<Devices size={24} color="#1976d2" variant="Bold" />
) : (
<Devices size={24} color="#82B1FF" />
),
path: '/security#sessions',
},
],
},
{
text: t('side.activeDevices'),
getIcon: (sel: boolean) =>
sel ? (
<Devices size={24} color="#1976d2" variant="Bold" />
) : (
<Devices size={24} color="#82B1FF" />
),
path: '/devices',
},
{
text: t('side.settings'),
getIcon: (sel: boolean) =>
sel ? (
<SettingIcon size={24} color="#1976d2" variant="Bold" />
) : (
<SettingIcon size={24} color="#82B1FF" />
),
path: '/setting',
},
];
}

View File

@@ -9,32 +9,11 @@ import {
Button,
Link,
CircularProgress,
Typography,
} from '@mui/material';
import { CloseCircle } from 'iconsax-react';
import { PasswordValidationItem } from './PasswordValidation';
import { Icon } from '@rkheftan/harmony-ui';
interface PasswordDialogProps {
open: boolean;
fullScreen: boolean;
handleClose: () => void;
password: string;
setPassword: (val: string) => void;
confirmPassword: string;
setConfirmPassword: (val: string) => void;
currentPassword: string;
setCurrentPassword: (val: string) => void;
showValidation: boolean;
hasNumber: boolean;
hasMinLength: boolean;
hasUpperAndLower: boolean;
hasSpecialChar: boolean;
matchPassword: boolean;
loading: boolean;
handleShowAlert: () => void;
changePassword: boolean;
t: (key: string) => string;
}
import { type PasswordDialogProps } from '../../types/settingsType';
export function PasswordDialog({
open,
@@ -47,14 +26,12 @@ export function PasswordDialog({
currentPassword,
setCurrentPassword,
showValidation,
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword,
matchPassword,
loading,
handleShowAlert,
handleSubmit,
changePassword,
apiError,
t,
}: PasswordDialogProps) {
return (
@@ -73,7 +50,9 @@ export function PasswordDialog({
<Icon Component={CloseCircle} size="large" color="primary.main" />
</IconButton>
<Box component="span" fontWeight="bold" fontSize={18}>
{t('securityForm.addPassword')}
{changePassword
? t('securityForm.changePassword')
: t('securityForm.addPassword')}
</Box>
</Box>
</DialogTitle>
@@ -86,16 +65,24 @@ export function PasswordDialog({
px: { xs: 2, sm: 3 },
}}
>
{/* ✅ 4. API ERROR DISPLAY ADDED */}
{apiError && (
<Typography color="error" variant="body2" textAlign="center">
{apiError}
</Typography>
)}
{changePassword && (
<>
<TextField
label={t('securityForm.currentPassword')}
type="password"
fullWidth
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 }, mt: 2 }}
/>
<Link sx={{ fontSize: '15px' }}>
<Link sx={{ fontSize: '15px', cursor: 'pointer' }}>
{t('securityForm.forgetPassword')}
</Link>
</>
@@ -110,27 +97,15 @@ export function PasswordDialog({
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
{password && showValidation && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{ maxWidth: '364px', width: '100%' }}>
<PasswordValidationItem
isValid={hasNumber}
label={t('securityForm.hasNumber')}
/>
<PasswordValidationItem
isValid={hasMinLength}
label={t('securityForm.hasMinLength')}
/>
<PasswordValidationItem
isValid={hasUpperAndLower}
label={t('securityForm.hasUpperAndLower')}
/>
<PasswordValidationItem
isValid={hasSpecialChar}
label={t('securityForm.hasSpecialChar')}
/>
</Box>
</Box>
{showValidation && (
<Typography
variant="body2"
color={validPassword ? 'success.main' : 'error.main'}
>
{validPassword
? t('securityForm.passwordIsValid')
: t('securityForm.passwordIsInvalid')}
</Typography>
)}
<TextField
@@ -154,10 +129,14 @@ export function PasswordDialog({
fullWidth
sx={{ height: 48, textTransform: 'none' }}
variant="contained"
onClick={handleShowAlert}
disabled={!password || password !== confirmPassword || loading}
onClick={handleSubmit}
disabled={!validPassword || !matchPassword || loading}
>
{loading ? <CircularProgress size={20} /> : t('securityForm.confirm')}
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
t('securityForm.confirm')
)}
</Button>
</DialogActions>
</Dialog>

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Button,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
@@ -10,9 +11,13 @@ import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import { PasswordDialog } from './PasswordDialog';
import { Toast } from '@/components/Toast';
import { regex } from '@/utils/regex';
import apiClient from '@/lib/apiClient';
import { useManualApi } from '@/hooks/useApi';
import {
addPassword,
changePassword,
fetchProfile,
} from '../../api/settingsApi';
export function PasswordSecurity() {
const theme = useTheme();
@@ -25,158 +30,161 @@ export function PasswordSecurity() {
const [currentPassword, setCurrentPassword] = useState('');
const [showValidation, setShowValidation] = useState(false);
const [showPasswordAlert, setShowPasswordAlert] = useState(false);
const [changePassword, setChangePassword] = useState(false);
const [loading, setLoading] = useState(false);
const [changePasswordUI, setChangePasswordUI] = useState(false);
const { validPassword } = regex(password);
const matchPassword = password === confirmPassword;
const {
hasNumber,
hasMinLength,
hasUpperAndLower,
hasSpecialChar,
validPassword,
} = regex(password);
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const matchPassword = password === confirmPassword;
const {
data: addData,
loading: isAdding,
error: addError,
execute: executeAddPassword,
} = useManualApi(addPassword);
const {
data: changeData,
loading: isChanging,
error: changeError,
execute: executeChangePassword,
} = useManualApi(changePassword);
useEffect(() => {
executeFetchProfile();
}, []);
useEffect(() => {
if (profileData?.success) {
setChangePasswordUI(profileData.hasPassword || false);
}
}, [profileData]);
useEffect(() => {
if (password) {
if (!validPassword) {
setShowValidation(true);
} else {
const timer = setTimeout(() => setShowValidation(false), 1000);
return () => clearTimeout(timer);
}
setShowValidation(!validPassword);
} else {
setShowValidation(false);
}
}, [password, validPassword]);
useEffect(() => {
if (addData?.success || changeData?.success) {
setShowPasswordAlert(true);
if (!changePasswordUI) {
setChangePasswordUI(true);
}
setOpen(false);
setPassword('');
setConfirmPassword('');
setCurrentPassword('');
}
}, [addData, changeData, changePasswordUI]);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handleShowAlert = async () => {
try {
setLoading(true);
const url = changePassword
? 'Profile/ChangePassword'
: 'Profile/AddPassword';
const payload = changePassword
? {
oldPassword: currentPassword,
newPassword: password,
}
: {
password,
// confirmPassword,
};
const res = await apiClient.post(url, payload, {
headers: {
Authorization: `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
const handlePasswordSubmit = () => {
if (changePasswordUI) {
executeChangePassword({
oldPassword: currentPassword,
newPassword: password,
});
if (res.data?.success) {
setShowPasswordAlert(true);
if (!changePassword) setChangePassword(true);
handleClose();
setPassword('');
setConfirmPassword('');
setCurrentPassword('');
} else {
console.error('Password update failed:', res.data?.message);
}
} catch (err) {
console.error('Error updating password:', err);
} finally {
setLoading(false);
} else {
executeAddPassword({ password });
}
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const apiError = useMemo(
() => addError || changeError,
[addError, changeError],
);
return (
<PageWrapper>
<CardContainer
title={t('securityForm.password')}
subtitle={t('securityForm.determinePassword')}
action={
<Button
variant="outlined"
onClick={handleOpen}
sx={{
backgroundColor: 'primary.main',
color: 'background.paper',
textTransform: 'none',
}}
>
{changePassword
<Button variant="contained" onClick={handleOpen}>
{changePasswordUI
? t('securityForm.changePassword')
: t('securityForm.addPassword')}
</Button>
}
>
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
flex: 1,
}}
>
{changePassword ? (
<Box sx={{ flexDirection: 'column', px: 2, py: 2 }}>
<Typography variant="h6">
{t('securityForm.activePassword')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('securityForm.lastChange')}
</Typography>
</Box>
) : (
<Typography
variant="body1"
color="text.secondary"
sx={{ textAlign: 'center', py: { xs: 4, sm: '43.5px' } }}
>
{t('securityForm.notDeterminedPassword')}
</Typography>
)}
<PasswordDialog
open={open}
fullScreen={fullScreen}
handleClose={handleClose}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
currentPassword={currentPassword}
setCurrentPassword={setCurrentPassword}
showValidation={showValidation}
hasNumber={hasNumber}
hasMinLength={hasMinLength}
hasUpperAndLower={hasUpperAndLower}
hasSpecialChar={hasSpecialChar}
matchPassword={matchPassword}
loading={loading}
handleShowAlert={handleShowAlert}
changePassword={changePassword}
t={t}
/>
<Toast
color="success"
open={showPasswordAlert}
onClose={() => setShowPasswordAlert(false)}
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '120px',
}}
>
{t('securityForm.alertSuccess')}
</Toast>
</Box>
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box sx={{ px: 4, py: 2 }}>
{changePasswordUI ? (
<Box>
<Typography variant="h6">
{t('securityForm.activePassword')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('securityForm.lastChange')}
</Typography>
</Box>
) : (
<Typography
variant="body1"
color="text.secondary"
sx={{ textAlign: 'center', py: 4 }}
>
{t('securityForm.notDeterminedPassword')}
</Typography>
)}
<PasswordDialog
open={open}
fullScreen={fullScreen}
handleClose={handleClose}
password={password}
setPassword={setPassword}
confirmPassword={confirmPassword}
setConfirmPassword={setConfirmPassword}
currentPassword={currentPassword}
setCurrentPassword={setCurrentPassword}
showValidation={showValidation}
validPassword={validPassword}
matchPassword={matchPassword}
loading={isAdding || isChanging}
handleSubmit={handlePasswordSubmit}
changePassword={changePasswordUI}
apiError={getErrorMessage(apiError)}
t={t}
/>
</Box>
)}
</CardContainer>
</PageWrapper>
);

View File

@@ -1,11 +1,7 @@
import { Box, Typography } from '@mui/material';
import { TickCircle } from 'iconsax-react';
import { Icon } from '@rkheftan/harmony-ui';
interface ValidationItemProps {
isValid: boolean;
label: string;
}
import { type ValidationItemProps } from '../../types/settingsType';
export function PasswordValidationItem({
isValid,

View File

@@ -2,116 +2,46 @@ import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
CircularProgress,
useTheme,
useMediaQuery,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
import apiClient from '@/lib/apiClient';
function formatLoginDate(isoDate: string, lang: string, t: TFunction): string {
const date = new Date(isoDate);
const now = new Date();
const diffInMinutes = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60),
);
if (diffInMinutes < 1) return t('active.justNow');
if (diffInMinutes < 60)
return t('active.minutesAgo', { count: diffInMinutes });
let displayLocale: string;
let options: Intl.DateTimeFormatOptions;
if (lang.startsWith('fa')) {
displayLocale = 'fa-IR';
options = {
calendar: 'persian',
numberingSystem: 'arab',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
} else {
displayLocale = 'en-US';
options = {
calendar: 'gregory',
numberingSystem: 'latn',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
}
return new Intl.DateTimeFormat(displayLocale, options).format(date);
}
interface LoginLog {
loginDateTime: string;
ipAddress: string;
deviceName: string;
deviceOs: string;
}
interface ProfileApiResponse {
success: boolean;
loginLogs?: LoginLog[];
}
import { fetchProfile } from '../../api/settingsApi';
import type { LoginLog } from '../../types/settingsApiType';
import { useManualApi } from '@/hooks/useApi';
import { formatDate } from '@/utils/formatSessionDate'; // ✅ 1. IMPORT the shared function
export function RecentLogins() {
const { t, i18n } = useTranslation('setting');
const token = localStorage.getItem('authToken');
const [logs, setLogs] = useState<LoginLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const theme = useTheme();
const isXsup = useMediaQuery(theme.breakpoints.up('xs'));
const {
data: profileData,
loading: isLoading,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
useEffect(() => {
const fetchLoginLogs = async () => {
setIsLoading(true);
setFetchError(null);
executeFetchProfile();
}, [i18n.language]);
if (!token) {
setIsLoading(false);
setFetchError(t('active.notLoggedIn'));
return;
}
useEffect(() => {
if (profileData?.success && Array.isArray(profileData.loginLogs)) {
setLogs(profileData.loginLogs);
}
}, [profileData]);
try {
const res = await apiClient.post<ProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success && Array.isArray(res.data.loginLogs)) {
setLogs(res.data.loginLogs);
} else {
throw new Error(t('active.failFetchActiveSessions'));
}
} catch (err) {
console.error(t('active.failFetchActiveSessions'), err);
setFetchError(t('active.errorFetch'));
} finally {
setIsLoading(false);
}
};
fetchLoginLogs();
}, [token, i18n.language, t]);
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
@@ -133,7 +63,9 @@ export function RecentLogins() {
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">{fetchError}</Typography>
<Typography color="error.main">
{getErrorMessage(fetchError) || t('active.errorFetch')}
</Typography>
</Box>
) : (
<Box sx={{ px: 4 }}>
@@ -142,45 +74,26 @@ export function RecentLogins() {
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'flex-start', sm: 'center' },
flexDirection: 'row',
alignItems: 'center',
minHeight: 50,
py: 1.5,
flexWrap: 'wrap',
gap: 2,
}}
>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '172.5px' },
}}
>
{formatLoginDate(log.loginDateTime, i18n.language, t)}
<Typography sx={{ flex: 1, minWidth: 160 }}>
{formatDate(log.loginDateTime, i18n.language, t)}
</Typography>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '172.5px' },
}}
>
<Typography sx={{ flex: 1, minWidth: 160 }}>
{`${log.deviceOs} ${log.deviceName}`}
</Typography>
<Typography
variant="body2"
sx={{
flexBasis: { xs: '100%', sm: 'auto' },
mb: { xs: 1, sm: 0 },
minWidth: { sm: '172.5px' },
}}
>
<Typography sx={{ flex: 1, minWidth: 120 }}>
{log.ipAddress}
</Typography>
</Box>
{isXsup && (
<Box sx={{ color: 'divider', borderBottom: '1px solid' }} />
{isXsup && index < logs.length - 1 && (
<Box sx={{ borderBottom: 1, borderColor: 'divider' }} />
)}
</React.Fragment>
))}

View File

@@ -14,7 +14,8 @@ import { ThemeToggleButton } from '@/components/ThemToggle';
import { PageWrapper } from '../PageWrapper';
import { Icon } from '@rkheftan/harmony-ui';
import { Sun1, Moon, Calendar1 } from 'iconsax-react';
import apiClient from '@/lib/apiClient';
import { useManualApi } from '@/hooks/useApi';
import { fetchProfile, saveSettings } from '../../api/settingsApi';
type ThemeMode = 'light' | 'dark';
type CalendarType = 'christian' | 'solar' | 'lunar';
@@ -25,23 +26,6 @@ interface SettingsState {
theme: ThemeMode;
}
interface UserSettingsFromApi {
theme: number;
calendarType: number;
language: number;
}
interface GetProfileApiResponse {
success: boolean;
message?: string;
userSettings?: UserSettingsFromApi;
}
interface SaveSettingApiResponse {
success: boolean;
message?: string;
}
const languageOptions = [
{ code: 'en', label: 'English', apiValue: 1 },
{ code: 'fa', label: 'فارسی', apiValue: 2 },
@@ -58,356 +42,275 @@ const themeApiMap: Record<ThemeMode, number> = { light: 1, dark: 2 };
export function Setting() {
const { t, i18n } = useTranslation(['setting']);
const { mode, setMode } = useColorScheme();
const token = localStorage.getItem('authToken');
const [savedSettings, setSavedSettings] = useState<SettingsState>({
language: i18n.language || 'en',
language: i18n.language,
calendar: 'solar',
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
});
const [draftSettings, setDraftSettings] =
useState<SettingsState>(savedSettings);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFetching, setIsFetching] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: saveData,
loading: isSaving,
error: saveError,
execute: executeSaveSettings,
} = useManualApi(saveSettings);
const notLoggedIn = t('settings.notLoggedIn');
const failRetrieve = t('settings.failRetrieve');
const errorFetch = t('settings.errorFetch');
useEffect(() => {
const fetchUserSettings = async () => {
setIsFetching(true);
setFetchError(null);
if (!token) {
setIsFetching(false);
setFetchError(notLoggedIn);
return;
}
try {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
executeFetchProfile();
}, []);
if (res.data.success && res.data.userSettings) {
const { theme, calendarType, language } = res.data.userSettings;
const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
1: 'light',
2: 'dark',
};
const themeMode = themeReverseMap[theme] || 'light';
const calendarSetting = calendarOptions.find(
(c) => c.apiValue === calendarType,
);
const calendarKey = calendarSetting ? calendarSetting.key : 'solar';
const languageSetting = languageOptions.find(
(l) => l.apiValue === language,
);
const languageCode = languageSetting ? languageSetting.code : 'en';
const newSettings: SettingsState = {
theme: themeMode,
calendar: calendarKey,
language: languageCode,
};
setSavedSettings(newSettings);
setDraftSettings(newSettings);
setMode(themeMode);
useEffect(() => {
if (profileData?.success && profileData.userSettings) {
const { theme, calendarType, language } = profileData.userSettings;
const themeReverseMap: { [key: number]: ThemeMode | undefined } = {
1: 'light',
2: 'dark',
};
const newSettings: SettingsState = {
theme: themeReverseMap[theme] || 'light',
calendar:
calendarOptions.find((c) => c.apiValue === calendarType)?.key ||
'solar',
language:
languageOptions.find((l) => l.apiValue === language)?.code || 'en',
};
setSavedSettings(newSettings);
setDraftSettings(newSettings);
setMode(newSettings.theme);
i18n.changeLanguage(newSettings.language);
}
}, [profileData, setMode, i18n]);
i18n.changeLanguage(languageCode);
} else {
throw new Error(res.data.message || failRetrieve);
}
} catch (e: unknown) {
let message = errorFetch;
if (e instanceof Error) {
message = e.message;
}
setFetchError(message);
} finally {
setIsFetching(false);
}
};
fetchUserSettings();
}, [token, setMode, i18n, notLoggedIn, failRetrieve, errorFetch]);
useEffect(() => {
if (saveData?.success) {
setMode(draftSettings.theme);
setSavedSettings(draftSettings);
i18n.changeLanguage(draftSettings.language);
setIsEditing(false);
}
}, [saveData, draftSettings, setMode, i18n]);
useEffect(() => {
if (isEditing) {
setDraftSettings({
...savedSettings,
theme: mode === 'light' || mode === 'dark' ? mode : 'light',
});
const resolvedMode = mode === 'light' || mode === 'dark' ? mode : 'light';
setDraftSettings({ ...savedSettings, theme: resolvedMode });
}
}, [isEditing, savedSettings, mode]);
const handleCancel = () => {
setIsEditing(false);
setError(null);
setDraftSettings(savedSettings);
setMode(savedSettings.theme);
};
const handleSave = async () => {
setError(null);
setLoading(true);
try {
const handleEditToggle = () => {
if (isEditing) {
const languageObj = languageOptions.find(
(o) => o.code === draftSettings.language,
);
const calendarObj = calendarOptions.find(
(c) => c.key === draftSettings.calendar,
);
const apiThemeValue = themeApiMap[draftSettings.theme];
if (!languageObj || !calendarObj) {
setError(t('settings.invalidSelection'));
setLoading(false);
return;
}
const res = await apiClient.post<SaveSettingApiResponse>(
'/Profile/SaveSetting',
{
theme: apiThemeValue,
if (languageObj && calendarObj) {
executeSaveSettings({
theme: themeApiMap[draftSettings.theme],
calendarType: calendarObj.apiValue,
language: languageObj.apiValue,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setMode(draftSettings.theme);
setSavedSettings(draftSettings);
await i18n.changeLanguage(draftSettings.language);
setIsEditing(false);
} else {
setError(res.data.message || t('settings.saveFailed'));
});
}
} catch (e: unknown) {
setError(t('settings.saveFailed'));
} finally {
setLoading(false);
} else {
setIsEditing(true);
}
};
const handleEditToggle = () => {
isEditing ? handleSave() : setIsEditing(true);
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
return (
<PageWrapper>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: { xs: 0, sm: 3 }, mx: 0 }}>
<CardContainer
title={t('settings.title')}
subtitle={t('settings.description')}
highlighted={isEditing}
action={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{isEditing && (
<Button
variant="text"
onClick={handleCancel}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
fontSize: { xs: '0.85rem', sm: '1rem' },
}}
disabled={loading || isFetching}
>
{t('settings.rejectButton')}
</Button>
)}
<Button
onClick={handleEditToggle}
size="large"
variant="outlined"
sx={{
border: '1px solid',
borderColor: 'primary.main',
borderRadius: 1,
bgcolor: isEditing ? 'primary.main' : 'background.default',
color: isEditing ? 'primary.contrastText' : 'primary.main',
}}
disabled={loading || isFetching}
>
{loading
? t('settings.saving')
: isEditing
? t('settings.saveButton')
: t('settings.editButton')}
</Button>
</Box>
}
<CardContainer
title={t('settings.title')}
subtitle={t('settings.description')}
highlighted={isEditing}
action={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{isEditing && (
<Button
variant="text"
onClick={handleCancel}
size="large"
sx={{
color: 'primary.main',
textTransform: 'none',
fontSize: { xs: '0.85rem', sm: '1rem' },
}}
disabled={isSaving || isFetching}
>
{t('settings.rejectButton')}
</Button>
)}
<Button
onClick={handleEditToggle}
size="large"
variant={isEditing ? 'contained' : 'outlined'}
disabled={isSaving || isFetching}
>
{isSaving
? t('settings.saving')
: isEditing
? t('settings.saveButton')
: t('settings.editButton')}
</Button>
</Box>
}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
{isFetching ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 4,
minHeight: '200px',
}}
>
<CircularProgress />
<CircularProgress />
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{getErrorMessage(saveError) && (
<Typography color="error.main" variant="body2" mb={2}>
{getErrorMessage(saveError)}
</Typography>
)}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 4,
mt: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.theme')}
</Typography>
{isEditing ? (
<ThemeToggleButton
value={draftSettings.theme}
onChange={(newTheme) => {
setDraftSettings((prev) => ({
...prev,
theme: newTheme,
}));
}}
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon
Component={savedSettings.theme === 'light' ? Sun1 : Moon}
size="medium"
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${savedSettings.theme}`)}
</Typography>
</Box>
)}
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">{fetchError}</Typography>
</Box>
) : (
<Box
sx={{
px: { xs: 2, sm: 3, md: 4 },
py: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: 'background.paper',
}}
>
{error && (
<Typography color="error.main" variant="body2" mb={2}>
{error}
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
{t('settings.language')}
</Typography>
{isEditing ? (
<Autocomplete
options={languageOptions}
getOptionLabel={(o) => o.label}
value={
languageOptions.find(
(o) => o.code === draftSettings.language,
) // ✅ FIX: Removed '|| null'
}
onChange={(_, v) =>
v &&
setDraftSettings((prev) => ({
...prev,
language: v.code,
}))
}
renderInput={(p) => <TextField {...p} />}
size="small"
fullWidth
disableClearable
/>
) : (
<Typography variant="body1">
{
languageOptions.find(
(o) => o.code === savedSettings.language,
)?.label
}
</Typography>
)}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
mt: 2,
}}
>
<Box sx={{ flex: 1 }}>
{isEditing ? (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Typography variant="body1" sx={{ mb: 1 }}>
{t('settings.theme')}
</Typography>
<ThemeToggleButton
value={draftSettings.theme}
onChange={(newTheme) => {
setDraftSettings((prev) => ({
...prev,
theme: newTheme,
}));
}}
/>
</Box>
) : (
<Box>
<Typography variant="caption" color="text.secondary">
{t('settings.theme')}
</Typography>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Icon
Component={
savedSettings.theme === 'light' ? Sun1 : Moon
}
size="medium"
variant="Bold"
color={
savedSettings.theme === 'light'
? 'text.primary'
: 'primary.main'
}
/>
<Typography variant="body1">
{t(`settings.${savedSettings.theme}`)}
</Typography>
</Box>
</Box>
)}
</Box>
<Box sx={{ flex: 1 }}>
{isEditing ? (
<Autocomplete
options={languageOptions}
getOptionLabel={(o) => o.label}
value={
languageOptions.find(
(o) => o.code === draftSettings.language,
) || null
}
onChange={(_, v) =>
v &&
setDraftSettings((prev) => ({
...prev,
language: v.code,
}))
}
renderInput={(p) => (
<TextField {...p} label={t('settings.language')} />
)}
size="medium"
fullWidth
/>
) : (
<Box>
<Typography variant="caption" color="text.secondary">
{t('settings.language')}
</Typography>
<Typography variant="body1">
{
languageOptions.find(
(o) => o.code === savedSettings.language,
)?.label
}
</Typography>
</Box>
)}
</Box>
</Box>
<Box sx={{ mt: 2, width: { xs: '100%', md: '50%' } }}>
{isEditing ? (
<Autocomplete
options={calendarOptions.map((c) => c.key)}
getOptionLabel={(key) => t(`settings.${key}`)}
value={draftSettings.calendar}
onChange={(_, v) =>
v &&
setDraftSettings((prev) => ({ ...prev, calendar: v }))
}
renderInput={(params) => (
<TextField {...params} label={t('settings.calendar')} />
)}
size="medium"
fullWidth
/>
) : (
<Box>
<Typography variant="caption" color="text.secondary">
{t('settings.calendar')}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Icon
Component={Calendar1}
size="medium"
color={mode === 'light' ? 'black' : 'primary.main'}
variant="Bold"
/>
<Typography variant="body1">
{t(`settings.${savedSettings.calendar}`)}
</Typography>
</Box>
</Box>
)}
</Box>
</Box>
)}
</CardContainer>
</Box>
</Box>
</Box>
<Box sx={{ mt: 2, width: { xs: '100%', sm: 'calc(50% - 16px)' } }}>
<Typography variant="caption" color="text.secondary">
{t('settings.calendar')}
</Typography>
{isEditing ? (
<Autocomplete
options={calendarOptions.map((c) => c.key)}
getOptionLabel={(key) => t(`settings.${key}`)}
value={draftSettings.calendar}
onChange={(_, v) =>
v && setDraftSettings((prev) => ({ ...prev, calendar: v }))
}
renderInput={(params) => <TextField {...params} />}
size="small"
fullWidth
disableClearable
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon Component={Calendar1} size="medium" variant="Bold" />
<Typography variant="body1">
{t(`settings.${savedSettings.calendar}`)}
</Typography>
</Box>
)}
</Box>
</Box>
)}
</CardContainer>
</PageWrapper>
);
}

View File

@@ -8,7 +8,7 @@ import { InfoRowEdit } from './personalInformation/InfoRowEdit';
import { PageWrapper } from '../PageWrapper';
import { useManualApi } from '@/hooks/useApi';
import { Gender, type InfoRowData } from '../../types/settingsType';
import { fetchProfile, saveProfile, getToken } from '../../api/settingsApi';
import { fetchProfile, saveProfile } from '../../api/settingsApi';
export function PersonalInformation() {
const { t } = useTranslation('setting');
@@ -37,13 +37,6 @@ export function PersonalInformation() {
execute: executeSaveProfile,
} = useManualApi(saveProfile);
const {
data: tokenData,
loading: isGettingToken,
error: tokenError,
execute: executeGetToken,
} = useManualApi(getToken);
useEffect(() => {
executeFetchProfile();
}, []);
@@ -91,10 +84,6 @@ export function PersonalInformation() {
executeSaveProfile({ data, imageUrl: uploadedImageUrl });
};
const handleGetTokenClick = async () => {
executeGetToken();
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
@@ -124,16 +113,6 @@ export function PersonalInformation() {
justifyContent: 'flex-end',
}}
>
<Button
variant="contained"
color="secondary"
onClick={handleGetTokenClick}
size="large"
sx={{ textTransform: 'none' }}
disabled={isLoadingProfile || isGettingToken || isSavingProfile}
>
{isGettingToken ? <CircularProgress size={24} /> : 'Get Token'}
</Button>
{isEditing ? (
<>
<Button
@@ -145,7 +124,6 @@ export function PersonalInformation() {
textTransform: 'none',
width: { xs: '100%', sm: 'auto' },
}}
disabled={isSavingProfile}
>
{t('settingForm.rejectButton')}
</Button>
@@ -186,14 +164,6 @@ export function PersonalInformation() {
{getErrorMessage(saveProfileError)}
</Typography>
)}
{tokenData?.success && (
<Typography
color="green"
sx={{ mt: 1, textAlign: 'right', width: '100%' }}
>
Token fetched and stored successfully!
</Typography>
)}
</Box>
}
>
@@ -218,11 +188,6 @@ export function PersonalInformation() {
</Box>
) : (
<>
{getErrorMessage(tokenError) && (
<Box sx={{ p: 2, mx: { xs: 2, sm: 3, md: 4 }, color: 'red' }}>
Error: {getErrorMessage(tokenError)}
</Box>
)}
<Box
sx={{
mx: { xs: 2, sm: 3, md: 4 },

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CardContainer } from '@/components/CardContainer';
import { PageWrapper } from '../PageWrapper';
@@ -7,8 +7,14 @@ import SocialMediaMenu from './socialMedia/SocialMediaMenu';
import SocialMediaDialog from './socialMedia/SocialMediaDialog';
import useMediaQuery from '@mui/material/useMediaQuery';
import type { Theme } from '@mui/material/styles';
import apiClient from '@/lib/apiClient';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useManualApi } from '@/hooks/useApi';
import {
fetchProfile,
sendEmailCode,
confirmEmailCode,
changeEmail,
} from '../../api/settingsApi';
interface EmailAccount {
email: string;
@@ -16,31 +22,8 @@ interface EmailAccount {
time: string;
}
interface GetProfileApiResponse {
success: boolean;
message?: string;
email?: string;
}
interface SendCodeApiResponse {
success: boolean;
message?: string;
}
interface ConfirmCodeApiResponse {
success: boolean;
confirm?: boolean;
message?: string;
}
interface ChangeEmailApiResponse {
success: boolean;
message?: string;
}
export function SocialMedia() {
const { t } = useTranslation('setting');
const token = localStorage.getItem('authToken');
const [openDialog, setOpenDialog] = useState(false);
const [emailInput, setEmailInput] = useState('');
@@ -48,12 +31,36 @@ export function SocialMedia() {
const [dialogStep, setDialogStep] = useState<'enterEmail' | 'enterCode'>(
'enterEmail',
);
const [apiError, setApiError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [emailList, setEmailList] = useState<EmailAccount[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const [isFetching, setIsFetching] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const {
data: profileData,
loading: isFetching,
error: fetchError,
execute: executeFetchProfile,
} = useManualApi(fetchProfile);
const {
data: sendCodeData,
loading: isSendingCode,
error: sendCodeError,
execute: executeSendCode,
} = useManualApi(sendEmailCode);
const {
data: confirmData,
loading: isConfirming,
error: confirmError,
execute: executeConfirmCode,
} = useManualApi(confirmEmailCode);
const {
data: changeEmailData,
loading: isChangingEmail,
error: changeEmailError,
execute: executeChangeEmail,
} = useManualApi(changeEmail);
const fullScreen = useMediaQuery((theme: Theme) =>
theme.breakpoints.down('sm'),
@@ -67,128 +74,85 @@ export function SocialMedia() {
| 'xl';
useEffect(() => {
const fetchInitialEmail = async () => {
setIsFetching(true);
setFetchError(null);
if (!token) {
setIsFetching(false);
setFetchError(t('settingForm.notLoggedIn'));
return;
}
try {
const res = await apiClient.post<GetProfileApiResponse>(
'/Profile/GetProfile',
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
executeFetchProfile();
}, []);
if (res.data.success && res.data.email) {
const userEmail = res.data.email;
const newAccount: EmailAccount = {
email: userEmail,
provider: userEmail.includes('gmail.com') ? 'google' : 'email',
time: '',
};
setEmailList([newAccount]);
} else if (!res.data.success) {
throw new Error(res.data.message || t('settingForm.failFetchEmail'));
}
} catch (err: unknown) {
let message = t('settingForm.errorFetchEmail');
if (err instanceof Error) {
message = err.message;
}
setFetchError(message);
} finally {
setIsFetching(false);
}
};
useEffect(() => {
if (profileData?.success && profileData.email) {
const { email } = profileData;
setEmailList([
{
email,
provider: email.includes('@gmail.') ? 'google' : 'email',
time: '',
},
]);
}
}, [profileData]);
fetchInitialEmail();
}, [token, t]);
useEffect(() => {
if (sendCodeData?.success) {
setDialogStep('enterCode');
}
}, [sendCodeData]);
useEffect(() => {
if (confirmData?.success && confirmData.confirm) {
executeChangeEmail({ email: emailInput });
}
}, [confirmData, emailInput, executeChangeEmail]);
useEffect(() => {
if (changeEmailData?.success) {
setEmailList((prev) => [
...prev,
{
email: emailInput,
provider: emailInput.includes('@gmail.') ? 'google' : 'email',
time: t('settingForm.justNow'),
},
]);
resetDialog();
}
}, [changeEmailData, emailInput, t]);
const resetDialog = () => {
setOpenDialog(false);
setEmailInput('');
setVerificationCode('');
setApiError(null);
setIsLoading(false);
setFormError(null);
setDialogStep('enterEmail');
};
const handleSendCode = async () => {
const handleSendCode = () => {
if (!/^\S+@\S+\.\S+$/.test(emailInput)) {
setApiError(t('settingForm.emailIsInvalid'));
setFormError(t('settingForm.emailIsInvalid'));
return;
}
setIsLoading(true);
setApiError(null);
try {
const res = await apiClient.post<SendCodeApiResponse>(
'Profile/SendEmailChangeCode',
{ email: emailInput },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (res.data.success) {
setDialogStep('enterCode');
} else {
setApiError(res.data.message || t('settingForm.sendCodeFailed'));
}
} catch (err: unknown) {
setApiError(t('settingForm.sendCodeFailed'));
} finally {
setIsLoading(false);
}
setFormError(null);
executeSendCode({ email: emailInput });
};
const handleConfirmAndChangeEmail = async () => {
const handleConfirmAndChangeEmail = () => {
if (verificationCode.length < 4) {
setApiError(t('settingForm.verificationCodeRequired'));
setFormError(t('settingForm.verificationCodeRequired'));
return;
}
setIsLoading(true);
setApiError(null);
try {
const confirmRes = await apiClient.post<ConfirmCodeApiResponse>(
'Profile/ConfirmEmailChangeCode',
{ email: emailInput, verifyCode: verificationCode },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (confirmRes.data.success && confirmRes.data.confirm) {
const changeRes = await apiClient.post<ChangeEmailApiResponse>(
'Profile/ChangeEmail',
{ email: emailInput },
{ headers: { Authorization: `Bearer ${token}` } },
);
if (changeRes.data.success) {
setEmailList((prevList) => [
...prevList,
{
email: emailInput,
provider: 'email',
time: t('settingForm.justNow'),
},
]);
resetDialog();
} else {
setApiError(
changeRes.data.message || t('settingForm.changeEmailFailed'),
);
}
} else {
setApiError(
confirmRes.data.message || t('settingForm.verifyCodeFailed'),
);
}
} catch (err: unknown) {
setApiError(t('settingForm.anErrorOccurred'));
} finally {
setIsLoading(false);
}
setFormError(null);
executeConfirmCode({ email: emailInput, verifyCode: verificationCode });
};
const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (error instanceof Error) return error.message;
return String(error);
};
const apiError = useMemo(
() => getErrorMessage(sendCodeError || confirmError || changeEmailError),
[sendCodeError, confirmError, changeEmailError],
);
return (
<PageWrapper>
<CardContainer
@@ -212,7 +176,9 @@ export function SocialMedia() {
</Box>
) : fetchError ? (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography color="error.main">{fetchError}</Typography>
<Typography color="error.main">
{getErrorMessage(fetchError)}
</Typography>
</Box>
) : (
<SocialMediaList t={t} emailList={emailList} />
@@ -225,8 +191,8 @@ export function SocialMedia() {
setEmailInput={setEmailInput}
verificationCode={verificationCode}
setVerificationCode={setVerificationCode}
apiError={apiError}
isLoading={isLoading}
apiError={formError || apiError}
isLoading={isSendingCode || isConfirming || isChangingEmail}
dialogStep={dialogStep}
onSendCode={handleSendCode}
onConfirmEmail={handleConfirmAndChangeEmail}

View File

@@ -9,7 +9,6 @@ import {
} from '@mui/material';
import { Edit2, TickCircle } from 'iconsax-react';
import { CountDownTimer } from '@/components/CountDownTimer';
import { Toast } from '@/components/Toast';
import { CountryCodeSelector } from '../../CountryCodeSelector';
import { Icon } from '@rkheftan/harmony-ui';
@@ -217,14 +216,6 @@ export default function PhoneEditForm({
</Button>
</Box>
)}
<Toast
color="success"
open={showToast}
onClose={() => setShowToast(false)}
>
{t('settingForm.successfulChangePhone')}
</Toast>
</>
);
}

View File

@@ -1,6 +0,0 @@
import { RouterProvider } from 'react-router-dom';
import { router } from '../components/layout/Routes';
export function Settings() {
return <RouterProvider router={router} />;
}

View File

@@ -1,5 +1,29 @@
import { Gender } from './settingsType';
/* General API Types */
export interface UserSettingsFromApi {
theme: number;
calendarType: number;
language: number;
}
export interface ApiSession {
key: string;
created: string;
deviceOs: string;
deviceName: string;
ipAddress: string;
}
export interface ActiveSessionsData {
sessions: ApiSession[];
currentKey: string;
}
export interface LoginLog {
loginDateTime: string;
ipAddress: string;
deviceName: string;
deviceOs: string;
}
/* Profile API Types */
export interface GetProfileApiResponse {
success: boolean;
@@ -10,9 +34,13 @@ export interface GetProfileApiResponse {
gender?: Gender;
countryCode?: string;
profileImageUrl?: string;
phoneNumber?: string; // ✅ ADDED
phoneNumber?: string;
userSettings?: UserSettingsFromApi;
activeSessions?: ActiveSessionsData;
loginLogs?: LoginLog[];
email?: string;
hasPassword?: boolean;
}
export interface SaveProfileApiResponse {
success: boolean;
message?: string;
@@ -20,8 +48,37 @@ export interface SaveProfileApiResponse {
email?: string;
}
export interface TokenApiResponse {
access_token: string;
/* Settings API Types */
export interface SaveSettingsApiResponse {
success: boolean;
message?: string;
}
/* Session API Types */
export interface DeleteSessionsApiResponse {
success: boolean;
message?: string;
}
/* Password API Types */
export interface PasswordApiResponse {
success: boolean;
message?: string;
}
/* Email API Types */
export interface SendEmailCodeApiResponse {
success: boolean;
message?: string;
}
export interface ConfirmEmailCodeApiResponse {
success: boolean;
confirm?: boolean;
message?: string;
}
export interface ChangeEmailApiResponse {
success: boolean;
message?: string;
}
/* Phone Number API Types */
@@ -29,7 +86,6 @@ export interface PhoneNumberApiResponse {
success: boolean;
message?: string;
}
export interface ConfirmPhoneNumberApiResponse extends PhoneNumberApiResponse {
confirm?: boolean;
}

View File

@@ -11,3 +11,36 @@ export interface InfoRowData {
country: string;
gender: Gender;
}
export interface Device {
id: string;
timeAndDate: string;
deviceModel: string;
ip: string;
current: boolean;
}
export interface PasswordDialogProps {
open: boolean;
fullScreen: boolean;
handleClose: () => void;
password: string;
setPassword: (val: string) => void;
confirmPassword: string;
setConfirmPassword: (val: string) => void;
currentPassword: string;
setCurrentPassword: (val: string) => void;
showValidation: boolean;
validPassword: boolean;
matchPassword: boolean;
loading: boolean;
handleSubmit: () => void;
changePassword: boolean;
apiError: string | null;
t: (key: string) => string;
}
export interface ValidationItemProps {
isValid: boolean;
label: string;
}