Files
emaui/libs/auth/src/lib/pages/LoginPage.tsx
2026-08-14 15:03:02 +03:00

269 lines
7.8 KiB
TypeScript

import { useState } from "react";
import {
Alert,
Anchor,
Button,
Checkbox,
Divider,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
UnstyledButton,
} from "@mantine/core";
import {
IconArrowLeft,
IconArrowRight,
IconDeviceMobile,
IconLock,
IconMail,
} from "@tabler/icons-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate, Link } from "react-router-dom";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useApiMutation } from "@ema-platform/api";
import { notify, useErrorHandler } from "@ema-platform/ui";
import { AuthShell } from "../components/AuthShell";
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
import type {
LoginPayload,
AuthUser,
CurrentProfile,
} from "../types/auth.types";
import { useAuthConfig } from "../AuthConfig";
import { authStorage } from "../utils/auth-storage";
export function LoginPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { t } = useTranslation();
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
useAuthConfig();
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [serverError, setServerError] = useState<string | null>(null);
const { handleError } = useErrorHandler();
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/");
}
};
// Built inside the component (not module scope) so validation messages
// pick up the active language — same pattern as ProfilePage's forms.
const schema = z.object({
email: 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: t(
"login.emailOrPhoneInvalid",
"Enter a valid email or phone number (+2519xxxxxxxx)",
),
},
),
password: z.string().min(8, {
message: t("login.passwordMinLength", "Password must be at least 8 characters"),
}),
});
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const data = await loginTrigger({
url: "/auth/login",
method: "POST",
body: values,
}).unwrap();
dispatch(loginSuccess(data));
const me = await meTrigger({
url: "/auth/me",
method: "GET",
}).unwrap();
dispatch(setUser(me));
// Warm the profile so screens that need an id have one on first paint.
// `/profiles/me` provisions an empty profile when the user has none, so
// unlike the old filtered lookup this cannot come back empty-handed.
// Sign-in is still never gated on it — a failure here is ignored and
// `useCurrentProfile` resolves it again on demand.
try {
const { profile } = await profileTrigger({
url: "/profiles/me",
method: "GET",
}).unwrap();
if (profile) {
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
}
} catch {
// Offline or a 5xx — the portal still works; the resolver retries.
}
if (!me.isPhoneNumberVerified) {
navigate("/otp-verify", {
state: {
email: me.email,
phoneNumber: me.phoneNumber,
},
});
return;
}
navigate(loginRedirectPath);
} catch (err: unknown) {
setServerError(handleError(err));
} finally {
localStorage.setItem("rememberMe", String(rememberMe));
setIsLoading(false);
}
};
return (
<AuthShell>
<Stack gap="lg">
<UnstyledButton
onClick={handleBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 14,
fontWeight: 600,
color: "var(--mantine-color-dimmed)",
cursor: "pointer",
width: "fit-content",
transition: "all 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--mantine-primary-color-filled)";
e.currentTarget.style.transform = "translateX(-3px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--mantine-color-dimmed)";
e.currentTarget.style.transform = "translateX(0)";
}}
>
<IconArrowLeft size={18} />
{t("common.back", "Back")}
</UnstyledButton>
<div>
<Title order={2} fz={30}>
{t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })}
</Title>
<Text c="dimmed" mt={6}>
{t("login.subtitle", "Sign in to access your account.")}
</Text>
</div>
{serverError && (
<Alert
variant="light"
color="red"
withCloseButton
onClose={() => setServerError(null)}
>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label={t("login.emailOrPhoneLabel", "Email or phone")}
placeholder={t("login.emailOrPhonePlaceholder", "you@example.com")}
size="md"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register("email")}
/>
<PasswordInput
label={t("login.passwordLabel", "Password")}
placeholder={t("login.passwordPlaceholder", "Your password")}
size="md"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register("password")}
/>
<Group justify="space-between">
<Checkbox
label={t("login.rememberMe", "Remember me")}
size="sm"
checked={rememberMe}
onChange={(e) => setRememberMe(e.currentTarget.checked)}
/>
{enableForgotPassword && (
<Anchor
component={Link}
to="/forgot-password"
size="sm"
fw={600}
>
{t("login.forgotPassword", "Forgot password?")}
</Anchor>
)}
</Group>
<Button
type="submit"
loading={isLoading}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
{t("login.signIn", "Sign in")}
</Button>
</Stack>
</form>
{enableSignup && (
<Text ta="center" size="sm" c="dimmed">
{t("login.noAccount", "Don't have an account? ")}
<Anchor component={Link} to="/signup" fw={700}>
{t("login.createOne", "Create one")}
</Anchor>
</Text>
)}
</Stack>
</AuthShell>
);
}