mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
276 lines
8.4 KiB
TypeScript
276 lines
8.4 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 { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
|
|
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 = () => {
|
|
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) => {
|
|
// A phone-looking value normalizes to E.164 (bare Ethiopian
|
|
// national numbers, e.g. 09xxxxxxxx, default to +251) so the
|
|
// international check below can validate it; anything else
|
|
// (an email) passes through untouched.
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (emailRegex.test(value)) return value;
|
|
return parsePhoneNumberFromString(value, "ET")?.number ?? value;
|
|
})
|
|
.refine(
|
|
(value) => {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(value) || isValidPhoneNumber(value);
|
|
},
|
|
{
|
|
message: t(
|
|
"login.emailOrPhoneInvalid",
|
|
"Enter a valid email or phone number",
|
|
),
|
|
},
|
|
),
|
|
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();
|
|
|
|
// Two-step verification on: the server withheld the tokens and mailed a
|
|
// one-time code instead. Storing this response would write an undefined
|
|
// token and 401 the very next request.
|
|
if (data.mfaRequired) {
|
|
navigate("/otp-verify", {
|
|
state: { mode: "mfa", email: values.email },
|
|
});
|
|
return;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|