826 lines
33 KiB
TypeScript
826 lines
33 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { loadFrappeTranslations } from '../i18n';
|
||
import { bootstrapFrappeUserFromSession } from '../utils/bootstrapFrappeUserFromSession';
|
||
|
||
interface LoginFormData {
|
||
email: string;
|
||
password: string;
|
||
}
|
||
|
||
const AFTER_PASSWORD_RESET_KEY = 'asm_show_after_password_reset';
|
||
const TWO_FACTOR_TMP_ID_KEY = 'asm_login_tmp_id';
|
||
|
||
type LoginStep = 'credentials' | 'otp';
|
||
|
||
const Login: React.FC = () => {
|
||
const [formData, setFormData] = useState<LoginFormData>({
|
||
email: '',
|
||
password: '',
|
||
});
|
||
const [loading, setLoading] = useState(false);
|
||
const [checkingSession, setCheckingSession] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [forgotOpen, setForgotOpen] = useState(false);
|
||
const [forgotEmail, setForgotEmail] = useState('');
|
||
const [forgotLoading, setForgotLoading] = useState(false);
|
||
const [forgotError, setForgotError] = useState<string | null>(null);
|
||
const [forgotMessage, setForgotMessage] = useState(false);
|
||
const [pwdResetBusy, setPwdResetBusy] = useState(false);
|
||
const [postResetBanner, setPostResetBanner] = useState(false);
|
||
const [loginStep, setLoginStep] = useState<LoginStep>('credentials');
|
||
const [tmpId, setTmpId] = useState<string | null>(null);
|
||
const [otpCode, setOtpCode] = useState('');
|
||
const [verification, setVerification] = useState<{
|
||
method: string;
|
||
setup?: boolean;
|
||
prompt?: string;
|
||
} | null>(null);
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { t } = useTranslation();
|
||
const manualLoginHandledRef = useRef(false);
|
||
const forgotAbortRef = useRef<AbortController | null>(null);
|
||
|
||
const closeForgotModal = useCallback(() => {
|
||
forgotAbortRef.current?.abort();
|
||
forgotAbortRef.current = null;
|
||
setForgotOpen(false);
|
||
setForgotLoading(false);
|
||
setForgotError(null);
|
||
setForgotMessage(false);
|
||
}, []);
|
||
|
||
const openForgotModal = useCallback(() => {
|
||
setForgotOpen(true);
|
||
setForgotEmail(formData.email);
|
||
setForgotError(null);
|
||
setForgotMessage(false);
|
||
setForgotLoading(false);
|
||
setError(null);
|
||
}, [formData.email]);
|
||
|
||
useEffect(() => {
|
||
if (!forgotOpen) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') closeForgotModal();
|
||
};
|
||
document.addEventListener('keydown', onKey);
|
||
return () => document.removeEventListener('keydown', onKey);
|
||
}, [forgotOpen, closeForgotModal]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
(async () => {
|
||
if (
|
||
typeof sessionStorage !== 'undefined' &&
|
||
sessionStorage.getItem(AFTER_PASSWORD_RESET_KEY) === '1'
|
||
) {
|
||
sessionStorage.removeItem(AFTER_PASSWORD_RESET_KEY);
|
||
if (!cancelled) setPostResetBanner(true);
|
||
}
|
||
|
||
const params = new URLSearchParams(
|
||
typeof window !== 'undefined' ? window.location.search : ''
|
||
);
|
||
if (params.get('manual_login') === '1' && !manualLoginHandledRef.current) {
|
||
manualLoginHandledRef.current = true;
|
||
if (!cancelled) setPwdResetBusy(true);
|
||
|
||
localStorage.removeItem('user');
|
||
localStorage.removeItem('frappe_session_id');
|
||
|
||
const baseURL = import.meta.env.VITE_FRAPPE_BASE_URL || '';
|
||
let csrf =
|
||
typeof window !== 'undefined'
|
||
? (window as { csrf_token?: string }).csrf_token
|
||
: undefined;
|
||
if (!csrf) {
|
||
try {
|
||
const csrfRes = await fetch(`${baseURL}/api/method/frappe.sessions.get_csrf_token`, {
|
||
method: 'GET',
|
||
credentials: 'include',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (csrfRes.ok) {
|
||
const csrfData = (await csrfRes.json()) as { message?: string };
|
||
if (typeof csrfData.message === 'string') csrf = csrfData.message;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
|
||
try {
|
||
await fetch(`${baseURL}/api/method/logout`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers,
|
||
});
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
|
||
if (typeof sessionStorage !== 'undefined') {
|
||
sessionStorage.setItem(AFTER_PASSWORD_RESET_KEY, '1');
|
||
}
|
||
|
||
if (cancelled) return;
|
||
setPwdResetBusy(false);
|
||
navigate('/login', { replace: true });
|
||
return;
|
||
}
|
||
|
||
if (localStorage.getItem('user')) {
|
||
if (!cancelled) {
|
||
setCheckingSession(false);
|
||
navigate('/dashboard', { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
|
||
const result = await bootstrapFrappeUserFromSession();
|
||
if (cancelled) return;
|
||
|
||
if (result.ok) {
|
||
try {
|
||
await loadFrappeTranslations();
|
||
} catch (err) {
|
||
console.warn('Could not load translations after session bootstrap:', err);
|
||
}
|
||
if (!cancelled) navigate('/dashboard', { replace: true });
|
||
}
|
||
if (!cancelled) setCheckingSession(false);
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [navigate, location.pathname, location.search]);
|
||
|
||
const baseUrl = import.meta.env.BASE_URL || '/';
|
||
const logoVersion = import.meta.env.DEV
|
||
? `?v=${Date.now()}`
|
||
: `?v=1774269853`; // Auto-updated by build script
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const { name, value } = e.target;
|
||
setFormData(prev => ({
|
||
...prev,
|
||
[name]: value,
|
||
}));
|
||
setError(null);
|
||
};
|
||
|
||
const completeLogin = useCallback(
|
||
async (user: { full_name?: string; user_id?: string; sid?: string; email?: string }) => {
|
||
const apiService = (await import('../services/apiService')).default;
|
||
const userData = {
|
||
...user,
|
||
email: user.email || formData.email,
|
||
};
|
||
localStorage.setItem('user', JSON.stringify(userData));
|
||
if (user.sid) {
|
||
apiService.setSessionId(user.sid);
|
||
}
|
||
sessionStorage.removeItem(TWO_FACTOR_TMP_ID_KEY);
|
||
try {
|
||
await loadFrappeTranslations();
|
||
} catch (err) {
|
||
console.warn('Could not load translations after login:', err);
|
||
}
|
||
navigate('/dashboard');
|
||
},
|
||
[formData.email, navigate]
|
||
);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const apiService = (await import('../services/apiService')).default;
|
||
const result = await apiService.login(formData);
|
||
|
||
if (result.status === 'two_factor_required') {
|
||
sessionStorage.setItem(TWO_FACTOR_TMP_ID_KEY, result.tmp_id);
|
||
setTmpId(result.tmp_id);
|
||
setVerification(result.verification);
|
||
setLoginStep('otp');
|
||
setOtpCode('');
|
||
return;
|
||
}
|
||
|
||
await completeLogin(result.user);
|
||
} catch (err: unknown) {
|
||
console.error('Login error:', err);
|
||
const message = err instanceof Error ? err.message : t('login.loginFailed');
|
||
setError(message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleOtpSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const storedTmpId = tmpId || sessionStorage.getItem(TWO_FACTOR_TMP_ID_KEY);
|
||
if (!storedTmpId) {
|
||
setError(t('login.twoFactorSessionExpired'));
|
||
setLoginStep('credentials');
|
||
return;
|
||
}
|
||
if (!otpCode.trim()) {
|
||
setError(t('login.twoFactorCodeRequired'));
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const apiService = (await import('../services/apiService')).default;
|
||
const result = await apiService.verifyLoginOtp(storedTmpId, otpCode);
|
||
if (result.status === 'logged_in') {
|
||
await completeLogin(result.user);
|
||
}
|
||
} catch (err: unknown) {
|
||
const message = err instanceof Error ? err.message : t('login.twoFactorInvalid');
|
||
setError(message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const backToCredentials = () => {
|
||
sessionStorage.removeItem(TWO_FACTOR_TMP_ID_KEY);
|
||
setLoginStep('credentials');
|
||
setTmpId(null);
|
||
setVerification(null);
|
||
setOtpCode('');
|
||
setError(null);
|
||
};
|
||
|
||
const handleDemoLogin = async () => {
|
||
const demoUser = {
|
||
full_name: 'Demo User',
|
||
email: 'demo@seeraarabia.com',
|
||
user_image: '',
|
||
roles: ['System Manager', 'Administrator']
|
||
};
|
||
|
||
localStorage.setItem('user', JSON.stringify(demoUser));
|
||
|
||
// Load translations from Frappe after demo login
|
||
try {
|
||
await loadFrappeTranslations();
|
||
} catch (err) {
|
||
console.warn('Could not load translations after demo login:', err);
|
||
}
|
||
|
||
navigate('/dashboard');
|
||
};
|
||
|
||
const handleForgotPasswordSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const userVal = forgotEmail.trim();
|
||
if (!userVal) {
|
||
setForgotError(t('login.forgotPasswordUserRequired'));
|
||
return;
|
||
}
|
||
forgotAbortRef.current?.abort();
|
||
const controller = new AbortController();
|
||
forgotAbortRef.current = controller;
|
||
|
||
setForgotLoading(true);
|
||
setForgotError(null);
|
||
setForgotMessage(false);
|
||
const forgotApi = await import('../services/apiService');
|
||
try {
|
||
await forgotApi.default.requestPasswordReset(userVal, controller.signal);
|
||
setForgotMessage(true);
|
||
} catch (err: unknown) {
|
||
if (err instanceof Error && err.name === 'AbortError') {
|
||
setForgotError(t('login.forgotPasswordTimeout'));
|
||
} else if (err instanceof forgotApi.ApiError) {
|
||
if (err.code === 'USER_NOT_FOUND') setForgotError(t('login.forgotPasswordNotFound'));
|
||
else if (err.code === 'RESET_NOT_ALLOWED') setForgotError(t('login.forgotPasswordCannotReset'));
|
||
else if (err.code === 'FORBIDDEN') setForgotError(t('login.forgotPasswordFailed'));
|
||
else if (err.code === 'EMPTY_EMAIL') setForgotError(t('login.forgotPasswordUserRequired'));
|
||
else setForgotError(t('login.forgotPasswordFailed'));
|
||
} else {
|
||
setForgotError(t('login.forgotPasswordFailed'));
|
||
}
|
||
} finally {
|
||
if (forgotAbortRef.current === controller) {
|
||
forgotAbortRef.current = null;
|
||
}
|
||
setForgotLoading(false);
|
||
}
|
||
};
|
||
|
||
if (checkingSession || pwdResetBusy) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||
<div className="flex flex-col items-center gap-3 text-gray-600 dark:text-gray-400">
|
||
<svg
|
||
className="h-10 w-10 animate-spin text-indigo-600"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
aria-hidden
|
||
>
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path
|
||
className="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
/>
|
||
</svg>
|
||
<span className="text-sm">
|
||
{pwdResetBusy ? t('login.finishingSignOut') : t('common.loading')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||
<div className="max-w-md w-full space-y-8">
|
||
<div>
|
||
<div className="flex justify-center mb-6">
|
||
<div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
|
||
<img
|
||
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
|
||
alt="Seera Arabia"
|
||
className="w-full h-full object-contain"
|
||
onError={(e) => {
|
||
const container = e.currentTarget.parentElement;
|
||
if (container) {
|
||
container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
|
||
}
|
||
e.currentTarget.style.display = 'none';
|
||
const nextSibling = e.currentTarget.nextElementSibling;
|
||
if (nextSibling) {
|
||
nextSibling.classList.remove('hidden');
|
||
}
|
||
}}
|
||
/>
|
||
<svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
|
||
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
|
||
<path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<h2 className="text-center text-3xl font-semibold text-gray-900 dark:text-white">
|
||
{t('login.title')}
|
||
</h2>
|
||
<p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||
{t('login.subtitle')}
|
||
</p>
|
||
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
|
||
{loginStep === 'otp' ? t('login.twoFactorTitle') : t('login.signIn')}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-8 space-y-6">
|
||
{postResetBanner && loginStep === 'credentials' && (
|
||
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||
<p className="text-sm text-green-800 dark:text-green-300">
|
||
{t('login.afterPasswordResetSignIn')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{loginStep === 'otp' ? (
|
||
<form className="space-y-6" onSubmit={handleOtpSubmit}>
|
||
<div className="rounded-md bg-indigo-50 p-4 text-sm text-indigo-900 dark:bg-indigo-900/20 dark:text-indigo-200">
|
||
{verification?.method === 'Email' && verification.prompt ? (
|
||
<p>{verification.prompt}</p>
|
||
) : verification?.method === 'OTP App' && verification.setup ? (
|
||
<p>{t('login.twoFactorOtpAppEnter')}</p>
|
||
) : verification?.method === 'OTP App' && !verification.setup ? (
|
||
<p>{t('login.twoFactorOtpAppSetupIncomplete')}</p>
|
||
) : (
|
||
<p>{t('login.twoFactorOtpAppEnter')}</p>
|
||
)}
|
||
{verification?.method === 'Email' && (
|
||
<p className="mt-2 text-xs text-indigo-800 dark:text-indigo-300">
|
||
{t('login.twoFactorEmailQrHint')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label htmlFor="otp" className="sr-only">
|
||
{t('login.twoFactorCodeLabel')}
|
||
</label>
|
||
<input
|
||
id="otp"
|
||
name="otp"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
maxLength={6}
|
||
required
|
||
autoFocus
|
||
className="relative block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-center text-lg tracking-widest text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||
placeholder={t('login.twoFactorCodePlaceholder')}
|
||
value={otpCode}
|
||
onChange={(ev) => {
|
||
setOtpCode(ev.target.value.replace(/\D/g, '').slice(0, 6));
|
||
setError(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
{error && (
|
||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="group relative flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{loading ? t('common.loading') : t('login.twoFactorVerify')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={backToCredentials}
|
||
className="w-full text-center text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||
>
|
||
{t('login.twoFactorBackToLogin')}
|
||
</button>
|
||
</form>
|
||
) : (
|
||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||
<div className="rounded-md shadow-sm -space-y-px">
|
||
<div>
|
||
<label htmlFor="email" className="sr-only">
|
||
{t('common.email')}
|
||
</label>
|
||
<input
|
||
id="email"
|
||
name="email"
|
||
type="email"
|
||
required
|
||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||
placeholder={t('login.emailPlaceholder')}
|
||
value={formData.email}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="password" className="sr-only">
|
||
{t('common.password')}
|
||
</label>
|
||
<input
|
||
id="password"
|
||
name="password"
|
||
type="password"
|
||
required
|
||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||
placeholder={t('login.passwordPlaceholder')}
|
||
value={formData.password}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{loading ? (
|
||
<div className="flex items-center">
|
||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
{t('common.loading')}
|
||
</div>
|
||
) : (
|
||
t('common.login')
|
||
)}
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{loginStep === 'credentials' && (
|
||
<div className="text-center">
|
||
<button
|
||
type="button"
|
||
onClick={openForgotModal}
|
||
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 focus:outline-none focus:underline"
|
||
>
|
||
{t('login.forgotPassword')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="hidden space-y-3" aria-hidden="true">
|
||
<div className="relative">
|
||
<div className="absolute inset-0 flex items-center">
|
||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||
</div>
|
||
<div className="relative flex justify-center text-sm">
|
||
<span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">{t('login.or')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleDemoLogin}
|
||
tabIndex={-1}
|
||
className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||
>
|
||
🚀 {t('login.demoLogin')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{forgotOpen && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||
onClick={closeForgotModal}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
className="relative w-full max-w-md rounded-lg border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||
onClick={(ev) => ev.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="forgot-password-title"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={closeForgotModal}
|
||
className="absolute right-3 top-3 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||
aria-label={t('login.forgotPasswordClose')}
|
||
>
|
||
×
|
||
</button>
|
||
<h3
|
||
id="forgot-password-title"
|
||
className="pr-8 text-lg font-semibold text-gray-900 dark:text-white"
|
||
>
|
||
{t('login.forgotPasswordTitle')}
|
||
</h3>
|
||
<p className="mt-2 text-xs leading-relaxed text-gray-600 dark:text-gray-400">
|
||
{t('login.forgotPasswordHint')}
|
||
</p>
|
||
<form className="mt-4 space-y-3" onSubmit={handleForgotPasswordSubmit}>
|
||
<input
|
||
type="text"
|
||
name="reset-user"
|
||
autoComplete="username"
|
||
value={forgotEmail}
|
||
onChange={(ev) => {
|
||
setForgotEmail(ev.target.value);
|
||
setForgotError(null);
|
||
setForgotMessage(false);
|
||
}}
|
||
className="relative block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-400"
|
||
placeholder={t('login.forgotPasswordUserPlaceholder')}
|
||
/>
|
||
{forgotError && (
|
||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
||
{forgotError}
|
||
</div>
|
||
)}
|
||
{forgotMessage && (
|
||
<div className="rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||
{t('login.forgotPasswordSentSuccess')}
|
||
</div>
|
||
)}
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={closeForgotModal}
|
||
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
{t('login.forgotPasswordClose')}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={forgotLoading}
|
||
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{forgotLoading ? t('common.loading') : t('login.forgotPasswordSubmit')}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Login;
|
||
|
||
|
||
|
||
// import React, { useState } from 'react';
|
||
// import { useNavigate } from 'react-router-dom';
|
||
// import { useAuth } from '../hooks/useApi';
|
||
|
||
// interface LoginFormData {
|
||
// email: string;
|
||
// password: string;
|
||
// }
|
||
|
||
// const Login: React.FC = () => {
|
||
// const [formData, setFormData] = useState<LoginFormData>({
|
||
// email: '',
|
||
// password: '',
|
||
// });
|
||
// const [loading, setLoading] = useState(false);
|
||
// const [error, setError] = useState<string | null>(null);
|
||
// const navigate = useNavigate();
|
||
// const { login } = useAuth();
|
||
|
||
// const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
// const { name, value } = e.target;
|
||
// setFormData(prev => ({
|
||
// ...prev,
|
||
// [name]: value,
|
||
// }));
|
||
// setError(null);
|
||
// };
|
||
|
||
// const handleSubmit = async (e: React.FormEvent) => {
|
||
// e.preventDefault();
|
||
// setLoading(true);
|
||
// setError(null);
|
||
|
||
// try {
|
||
// const response = await login(formData);
|
||
|
||
// if (response && response.message) {
|
||
// // Store user info in localStorage with email field
|
||
// const userData = {
|
||
// ...response.message,
|
||
// email: formData.email // Ensure email is stored
|
||
// };
|
||
// localStorage.setItem('user', JSON.stringify(userData));
|
||
// navigate('/dashboard');
|
||
// } else {
|
||
// setError('Login failed. Please check your credentials.');
|
||
// }
|
||
// } catch (err: any) {
|
||
// setError(err.message || 'Login failed. Please try again.');
|
||
// } finally {
|
||
// setLoading(false);
|
||
// }
|
||
// };
|
||
|
||
// const handleDemoLogin = () => {
|
||
// // Create dummy user data for demo purposes
|
||
// const demoUser = {
|
||
// full_name: 'Demo User',
|
||
// email: 'demo@seeraarabia.com',
|
||
// user_image: '',
|
||
// roles: ['System Manager', 'Administrator']
|
||
// };
|
||
|
||
// // Store demo user in localStorage
|
||
// localStorage.setItem('user', JSON.stringify(demoUser));
|
||
// navigate('/dashboard');
|
||
// };
|
||
|
||
// return (
|
||
// <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||
// <div className="max-w-md w-full space-y-8">
|
||
// <div>
|
||
// <div className="flex justify-center mb-6">
|
||
// <div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
|
||
// {/* Seera Arabia Logo */}
|
||
// <img
|
||
// src="/seera-logo.png?v=1765198405"
|
||
// alt="Seera Arabia"
|
||
// className="w-full h-full object-contain"
|
||
// onError={(e) => {
|
||
// // Fallback to gradient background with SVG if image not found
|
||
// const container = e.currentTarget.parentElement;
|
||
// if (container) {
|
||
// container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
|
||
// }
|
||
// e.currentTarget.style.display = 'none';
|
||
// e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||
// }}
|
||
// />
|
||
// <svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
// <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
|
||
// <path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
|
||
// <path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
|
||
// </svg>
|
||
// </div>
|
||
// </div>
|
||
// <h2 className="text-center text-3xl font-semibold text-gray-900 dark:text-white">
|
||
// Seera Arabia
|
||
// </h2>
|
||
// <p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||
// Asset Management System
|
||
// </p>
|
||
// <p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
|
||
// Sign in to continue
|
||
// </p>
|
||
// </div>
|
||
|
||
// <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||
// <div className="rounded-md shadow-sm -space-y-px">
|
||
// <div>
|
||
// <label htmlFor="email" className="sr-only">
|
||
// Email
|
||
// </label>
|
||
// <input
|
||
// id="email"
|
||
// name="email"
|
||
// type="email"
|
||
// required
|
||
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||
// placeholder="Email"
|
||
// value={formData.email}
|
||
// onChange={handleChange}
|
||
// />
|
||
// </div>
|
||
// <div>
|
||
// <label htmlFor="password" className="sr-only">
|
||
// Password
|
||
// </label>
|
||
// <input
|
||
// id="password"
|
||
// name="password"
|
||
// type="password"
|
||
// required
|
||
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||
// placeholder="Password"
|
||
// value={formData.password}
|
||
// onChange={handleChange}
|
||
// />
|
||
// </div>
|
||
// </div>
|
||
|
||
// {error && (
|
||
// <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||
// <div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||
// </div>
|
||
// )}
|
||
|
||
// <div className="space-y-3">
|
||
// <button
|
||
// type="submit"
|
||
// disabled={loading}
|
||
// className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
// >
|
||
// {loading ? (
|
||
// <div className="flex items-center">
|
||
// <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
// <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
// <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
// </svg>
|
||
// Signing in...
|
||
// </div>
|
||
// ) : (
|
||
// 'Sign in'
|
||
// )}
|
||
// </button>
|
||
|
||
// <div className="relative">
|
||
// <div className="absolute inset-0 flex items-center">
|
||
// <div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||
// </div>
|
||
// <div className="relative flex justify-center text-sm">
|
||
// <span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">or</span>
|
||
// </div>
|
||
// </div>
|
||
|
||
// <button
|
||
// type="button"
|
||
// onClick={handleDemoLogin}
|
||
// className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||
// >
|
||
// 🚀 {t('login.demoLogin')}
|
||
// </button>
|
||
// </div>
|
||
// </form>
|
||
// </div>
|
||
// </div>
|
||
// );
|
||
// };
|
||
|
||
// export default Login;
|