feat: add authorization

This commit is contained in:
2026-08-13 15:03:59 +03:00
parent 1e13adabd3
commit 3448fea140
28 changed files with 4504 additions and 3707 deletions
+42
View File
@@ -0,0 +1,42 @@
import { MantineProvider } from "@mantine/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AuthForm } from "./AuthForm";
describe("AuthForm", () => {
afterEach(cleanup);
it("does not submit registration when password confirmation differs", async () => {
const onSubmit = vi.fn();
render(
<MantineProvider>
<MemoryRouter>
<AuthForm register onSubmit={onSubmit} />
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" },
});
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
target: { value: "another secure password" },
});
fireEvent.click(screen.getByRole("button", { name: "Регистрация" }));
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByText("Пароли не совпадают")).toBeInTheDocument(),
);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { z } from "zod";
import { validationMessage } from "../../../shared/i18n";
function isEmailCharacterInvalid(character: string): boolean {
return (
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127
);
}
const email = z
.string()
.trim()
.toLowerCase()
.max(254)
.refine(
(value) =>
value.split("@").length === 2 &&
value.split("@").every(Boolean) &&
!value.split("").some(isEmailCharacterInvalid),
validationMessage("auth.validation.invalidEmail"),
);
const password = z
.string()
.refine(
(value) =>
new TextEncoder().encode(value).length >= 12 &&
new TextEncoder().encode(value).length <= 72,
validationMessage("auth.validation.invalidPasswordLength"),
);
const loginSchema = z.object({
email,
password,
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
(value) => value.password === value.passwordConfirmation,
(value) => ({
path: ["passwordConfirmation"],
...validationMessage("auth.validation.passwordsDoNotMatch")(value),
}),
);
type AuthFormValues = z.input<typeof loginSchema>;
export function AuthForm({
register,
onSubmit,
}: {
register?: true;
onSubmit: (email: string, password: string) => Promise<void>;
}) {
const { t } = useTranslation();
const schema = register ? registerSchema : loginSchema;
const methods = useForm<AuthFormValues>({
resolver: zodResolver(schema) as Resolver<AuthFormValues>,
});
const {
formState: { errors, isSubmitting },
} = methods;
const [submitError, setSubmitError] = useState<string | null>(null);
return (
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(async (values) => {
setSubmitError(null);
try {
await onSubmit(values.email, values.password);
} catch {
setSubmitError(t("auth.requestError"));
}
})}
>
<fieldset disabled={isSubmitting}>
<Stack>
<TextInput
label={t("auth.email")}
{...methods.register("email")}
error={errors.email?.message as string}
/>
<PasswordInput
label={t("auth.password")}
{...methods.register("password")}
error={errors.password?.message as string}
/>
{register && (
<PasswordInput
label={t("auth.passwordConfirmation")}
{...methods.register("passwordConfirmation")}
error={errors.passwordConfirmation?.message as string}
/>
)}
{submitError && <Alert color="red">{submitError}</Alert>}
<Button type="submit" loading={isSubmitting}>
{t(register ? "auth.register" : "auth.login")}
</Button>
<Button
component={Link}
to={register ? "/login" : "/register"}
variant="subtle"
>
{t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button>
</Stack>
</fieldset>
</form>
</FormProvider>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth";
import { AuthForm } from "./AuthForm";
export function LoginForm() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
return (
<AuthForm
onSubmit={async (email, password) => {
await login(email, password);
navigate(returnTo, { replace: true });
}}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth";
import { AuthForm } from "./AuthForm";
export function RegisterForm() {
const { register } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
return (
<AuthForm
register
onSubmit={async (email, password) => {
await register(email, password);
navigate(returnTo, { replace: true });
}}
/>
);
}