feat: add authorization
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export { AuthProvider, useAuth } from "./model/auth";
|
||||
export { LoginForm } from "./ui/LoginForm";
|
||||
export { RegisterForm } from "./ui/RegisterForm";
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { apiClient } from "../../../shared/api";
|
||||
import type { paths } from "../../../shared/api/generated/schema";
|
||||
|
||||
type User =
|
||||
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type Authentication =
|
||||
paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
||||
type Credentials =
|
||||
paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"];
|
||||
type AuthContextValue = {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
ready: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
async function request(
|
||||
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<Authentication> {
|
||||
const { data, error } = await apiClient.POST(path, {
|
||||
body: { email, password },
|
||||
} satisfies { body: Credentials });
|
||||
if (error) throw error;
|
||||
return data as Authentication;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const accessTokenRef = useRef<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const refreshPromise = useRef<Promise<boolean> | null>(null);
|
||||
|
||||
async function refresh(): Promise<boolean> {
|
||||
if (refreshPromise.current) return refreshPromise.current;
|
||||
refreshPromise.current = (async () => {
|
||||
const run = async () => {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (!data) return false;
|
||||
const authentication = data as Authentication;
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
return true;
|
||||
};
|
||||
return "locks" in navigator
|
||||
? navigator.locks.request("wealth-ledger-refresh", run)
|
||||
: run();
|
||||
})().finally(() => {
|
||||
refreshPromise.current = null;
|
||||
});
|
||||
return refreshPromise.current;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void apiClient
|
||||
.POST("/api/v1/auth/refresh")
|
||||
.then(({ data }) => {
|
||||
if (!data) return;
|
||||
const authentication = data as Authentication;
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
})
|
||||
.finally(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
async function authenticate(
|
||||
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
const authentication = await request(path, email, password);
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiClient.POST("/api/v1/auth/logout");
|
||||
} finally {
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
accessTokenRef.current = null;
|
||||
queryClient.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext
|
||||
value={{
|
||||
user,
|
||||
accessToken,
|
||||
ready,
|
||||
login: (email, password) =>
|
||||
authenticate("/api/v1/auth/login", email, password),
|
||||
register: (email, password) =>
|
||||
authenticate("/api/v1/auth/register", email, password),
|
||||
logout,
|
||||
refresh,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error("AuthProvider is required");
|
||||
return context;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user