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

@@ -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>
))}