This commit is contained in:
Estifo77
2026-08-08 09:05:06 +03:00
parent 203d2e5026
commit a92580c7be

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState } from 'react';
import { import {
Alert, Alert,
Anchor, Anchor,
@@ -11,56 +11,29 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from '@mantine/core';
import { import {
IconArrowRight, IconArrowRight,
IconDeviceMobile, IconDeviceMobile,
IconLock, IconLock,
IconMail, IconMail,
} from "@tabler/icons-react"; } from '@tabler/icons-react';
import { useForm } from "react-hook-form"; import { useForm } from 'react-hook-form';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from "zod"; import { z } from 'zod';
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from "react-redux"; import { useDispatch } from 'react-redux';
import { useApiMutation } from "@ema-platform/api"; import { useApiMutation } from '@ema-platform/api';
import { notify, useErrorHandler } from "@ema-platform/ui"; import { notify } from '@ema-platform/ui';
import { AuthShell } from "../components/AuthShell"; import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice"; import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice';
import type { import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types';
LoginPayload, import { useAuthConfig } from '../AuthConfig';
AuthUser, import { authStorage } from '../utils/auth-storage';
CurrentProfile,
} from "../types/auth.types";
import { useAuthConfig } from "../AuthConfig";
import { authStorage } from "../utils/auth-storage";
const emailOrPhone = z
.string()
.trim()
.transform((value) => {
// Convert 09xxxxxxxx -> +2519xxxxxxxx
if (/^09\d{8}$/.test(value)) {
return `+251${value.substring(1)}`;
}
return value;
})
.refine(
(value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\+2519\d{8}$/;
return emailRegex.test(value) || phoneRegex.test(value);
},
{
message: "Enter a valid email or phone number (+2519xxxxxxxx)",
},
);
const schema = z.object({ const schema = z.object({
email: emailOrPhone, email: z.string().email({ message: 'Enter a valid email' }),
password: z password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
.string()
.min(8, { message: "Password must be at least 8 characters" }),
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
@@ -73,7 +46,9 @@ export function LoginPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true); const [rememberMe, setRememberMe] = useState(true);
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const { handleError } = useErrorHandler(); // MFA second step: set once /auth/login answers `mfaRequired` (US-IAM-006).
const [mfaEmail, setMfaEmail] = useState<string | null>(null);
const [mfaOtp, setMfaOtp] = useState('');
const [loginTrigger] = useApiMutation<LoginPayload>(); const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>(); const [meTrigger] = useApiMutation<AuthUser>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>(); const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
@@ -90,8 +65,8 @@ export function LoginPage() {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await loginTrigger({ const data = await loginTrigger({
url: "/auth/login", url: '/auth/login',
method: "POST", method: 'POST',
body: values, body: values,
}).unwrap(); }).unwrap();
@@ -139,8 +114,8 @@ export function LoginPage() {
dispatch(loginSuccess(data)); dispatch(loginSuccess(data));
const me = await meTrigger({ const me = await meTrigger({
url: "/auth/me", url: '/auth/me',
method: "GET", method: 'GET',
}).unwrap(); }).unwrap();
dispatch(setUser(me)); dispatch(setUser(me));
@@ -151,8 +126,8 @@ export function LoginPage() {
// `useCurrentProfile` resolves it again on demand. // `useCurrentProfile` resolves it again on demand.
try { try {
const { profile } = await profileTrigger({ const { profile } = await profileTrigger({
url: "/profiles/me", url: '/profiles/me',
method: "GET", method: 'GET',
}).unwrap(); }).unwrap();
if (profile) { if (profile) {
authStorage.setProfileId(profile.id); authStorage.setProfileId(profile.id);
@@ -162,23 +137,17 @@ export function LoginPage() {
// Offline or a 5xx — the portal still works; the resolver retries. // Offline or a 5xx — the portal still works; the resolver retries.
} }
if (!me.isPhoneNumberVerified) { if (!me.isPhoneNumberVerified) {
navigate("/otp-verify", { navigate('/otp-verify', {
state: { state: {
email: me.email, email: me.email,
phoneNumber: me.phoneNumber, phoneNumber: me.phoneNumber,
}, },
}); });
return; return;
}
navigate(loginRedirectPath);
} catch (err: unknown) {
setServerError(handleError(err));
} finally {
localStorage.setItem("rememberMe", String(rememberMe));
setIsLoading(false);
} }
navigate(loginRedirectPath);
}; };
return ( return (
@@ -194,12 +163,7 @@ export function LoginPage() {
</div> </div>
{serverError && ( {serverError && (
<Alert <Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
variant="light"
color="red"
withCloseButton
onClose={() => setServerError(null)}
>
{serverError} {serverError}
</Alert> </Alert>
)} )}
@@ -248,7 +212,7 @@ export function LoginPage() {
size="md" size="md"
leftSection={<IconMail size={18} />} leftSection={<IconMail size={18} />}
error={errors.email?.message} error={errors.email?.message}
{...register("email")} {...register('email')}
/> />
<PasswordInput <PasswordInput
label="Password" label="Password"
@@ -256,7 +220,7 @@ export function LoginPage() {
size="md" size="md"
leftSection={<IconLock size={18} />} leftSection={<IconLock size={18} />}
error={errors.password?.message} error={errors.password?.message}
{...register("password")} {...register('password')}
/> />
<Group justify="space-between"> <Group justify="space-between">
@@ -289,9 +253,23 @@ export function LoginPage() {
</Button> </Button>
</Stack> </Stack>
</form> </form>
)}
<Divider label="or" labelPosition="center" />
<Button
variant="default"
fullWidth
size="md"
leftSection={<IconDeviceMobile size={18} />}
onClick={() => notify.info('Phone sign-in is coming soon.')}
>
Sign in with phone number
</Button>
{enableSignup && ( {enableSignup && (
<Text ta="center" size="sm" c="dimmed"> <Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{" "} Don&apos;t have an account?{' '}
<Anchor component={Link} to="/signup" fw={700}> <Anchor component={Link} to="/signup" fw={700}>
Create one Create one
</Anchor> </Anchor>