refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export { AuthProvider } from "./model/auth";
|
||||
export { useAuth } from "./model/auth-context";
|
||||
export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout";
|
||||
export { LoginForm } from "./ui/LoginForm";
|
||||
export { RegisterForm } from "./ui/RegisterForm";
|
||||
export { LoginForm } from "./ui/LoginForm/LoginForm";
|
||||
export { RegisterForm } from "./ui/RegisterForm/RegisterForm";
|
||||
|
||||
@@ -11,13 +11,16 @@ type AuthContextValue = {
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
user: undefined | User;
|
||||
};
|
||||
|
||||
type User
|
||||
= paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
function useAuth() {
|
||||
const context = use(AuthContext);
|
||||
if (!context) throw new Error("AuthProvider is required");
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const MAX_PASSWORD_BYTES = 72;
|
||||
|
||||
function isEmailCharacterInvalid(character: string): boolean {
|
||||
const codePoint = character.codePointAt(0);
|
||||
|
||||
return (
|
||||
/\s/.test(character)
|
||||
|| (codePoint !== undefined && codePoint < ASCII_SPACE)
|
||||
@@ -31,6 +32,7 @@ const email = z
|
||||
&& value.split("").every(element => !isEmailCharacterInvalid(element)),
|
||||
{ error: validationMessage("auth.validation.invalidEmail") },
|
||||
);
|
||||
|
||||
const password = z
|
||||
.string()
|
||||
.refine(
|
||||
@@ -45,6 +47,7 @@ const loginSchema = z.object({
|
||||
password,
|
||||
passwordConfirmation: z.string().optional(),
|
||||
});
|
||||
|
||||
const registerSchema = loginSchema.refine(
|
||||
value => value.password === value.passwordConfirmation,
|
||||
{
|
||||
@@ -17,7 +17,9 @@ const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
|
||||
params: { query: { includeArchived: "false" } },
|
||||
}).queryKey;
|
||||
|
||||
function createAuthWrapper(queryClient: ReturnType<typeof createTestQueryClient>): FC<PropsWithChildren> {
|
||||
function createAuthWrapper(
|
||||
queryClient: ReturnType<typeof createTestQueryClient>,
|
||||
): FC<PropsWithChildren> {
|
||||
return props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>{props.children}</AuthProvider>
|
||||
@@ -27,7 +29,10 @@ function createAuthWrapper(queryClient: ReturnType<typeof createTestQueryClient>
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
it("authorizes requests after restoring the initial session", async () => {
|
||||
let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
|
||||
let authorization: null | string
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
= null;
|
||||
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
HttpResponse.json({
|
||||
@@ -37,11 +42,13 @@ describe("AuthProvider", () => {
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
|
||||
return HttpResponse.json(resource([]));
|
||||
}),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||
|
||||
const { rerender, result } = renderHook(
|
||||
() => ({ ...useAuth(), queryClient: useQueryClient() }),
|
||||
{ wrapper },
|
||||
@@ -80,6 +87,7 @@ describe("AuthProvider", () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () => {
|
||||
refreshCalls += 1;
|
||||
|
||||
return refreshCalls === 1
|
||||
? HttpResponse.json({
|
||||
accessToken: "restored-token",
|
||||
@@ -119,15 +127,18 @@ describe("AuthProvider", () => {
|
||||
),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const statusKey = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/tbank-connection",
|
||||
).queryKey;
|
||||
|
||||
const portfolioKey = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: "account-1" } } },
|
||||
).queryKey;
|
||||
|
||||
queryClient.setQueryData(statusKey, {
|
||||
connected: true,
|
||||
connectedAt: "2026-08-14T00:00:00Z",
|
||||
@@ -157,7 +168,9 @@ describe("AuthProvider", () => {
|
||||
])(
|
||||
"serializes late refresh (failure=%s, login=%s)",
|
||||
async (failure, loginAfterLogout) => {
|
||||
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
|
||||
const { promise: pending, resolve: release }
|
||||
= Promise.withResolvers<void>();
|
||||
|
||||
let calls = 0;
|
||||
const order: string[] = [];
|
||||
server.use(
|
||||
@@ -167,6 +180,7 @@ describe("AuthProvider", () => {
|
||||
await pending;
|
||||
order.push("refresh-cookie");
|
||||
}
|
||||
|
||||
return failure && calls > 1
|
||||
? HttpResponse.error()
|
||||
: HttpResponse.json({
|
||||
@@ -176,10 +190,12 @@ describe("AuthProvider", () => {
|
||||
}),
|
||||
http.post("*/api/v1/auth/logout", () => {
|
||||
order.push("logout-cookie");
|
||||
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
http.post("*/api/v1/auth/login", () => {
|
||||
order.push("login-cookie");
|
||||
|
||||
return HttpResponse.json({
|
||||
accessToken: "b",
|
||||
user: { email: "b@example.com", id: "b" },
|
||||
@@ -193,11 +209,13 @@ describe("AuthProvider", () => {
|
||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
|
||||
const oldRequest = apiClient
|
||||
.GET("/api/v1/brokerage-accounts")
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query.
|
||||
.catch(() => {});
|
||||
|
||||
await waitFor(() => expect(order).toContain("refresh-start"));
|
||||
let logout!: Promise<void>;
|
||||
act(() => {
|
||||
@@ -214,8 +232,14 @@ describe("AuthProvider", () => {
|
||||
release();
|
||||
await Promise.all([oldRequest, logout, login]);
|
||||
});
|
||||
expect(result.current.user?.id ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe(
|
||||
loginAfterLogout ? "b" : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null,
|
||||
expect(
|
||||
result.current.user?.id
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
?? null,
|
||||
).toBe(
|
||||
loginAfterLogout
|
||||
? "b"
|
||||
: null, // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
);
|
||||
expect(order).toEqual([
|
||||
"refresh-start",
|
||||
@@ -244,17 +268,20 @@ describe("AuthProvider", () => {
|
||||
await act(() => result.current.refresh());
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
|
||||
let release!: (value: string) => void;
|
||||
|
||||
const pending = queryClient
|
||||
.query({
|
||||
queryFn: () => {
|
||||
const deferred = Promise.withResolvers<string>();
|
||||
release = deferred.resolve;
|
||||
|
||||
return deferred.promise;
|
||||
},
|
||||
queryKey: ["pending-finance"],
|
||||
})
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query.
|
||||
.catch(() => {});
|
||||
|
||||
identity = "b";
|
||||
await act(() => result.current.refresh());
|
||||
release("a-data");
|
||||
@@ -278,6 +305,7 @@ describe("AuthProvider", () => {
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
auth: useAuth(),
|
||||
@@ -289,6 +317,7 @@ describe("AuthProvider", () => {
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.cards.data?.[0].id).toBe("a-card"),
|
||||
);
|
||||
|
||||
@@ -1,157 +1,12 @@
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||
|
||||
import { AuthContext, type User } from "./auth-context";
|
||||
|
||||
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"];
|
||||
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 });
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
import { AuthContext } from "./auth-context";
|
||||
import { useAuthProviderVm } from "./auth.vm";
|
||||
|
||||
const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<undefined | User>(undefined);
|
||||
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
|
||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||
const [ready, setReady] = useState(false);
|
||||
const value = useAuthProviderVm();
|
||||
|
||||
const generationRef = useRef(0);
|
||||
const userRef = useRef<undefined | User>(undefined);
|
||||
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
|
||||
// Cookie-writing requests share ordering, including across tabs with Web Locks.
|
||||
const serialize = useCallback(<T,>(run: () => Promise<T>): Promise<T> => {
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
const next = authQueueRef.current.then(async () =>
|
||||
"locks" in navigator
|
||||
? navigator.locks.request("wealth-ledger-refresh", run)
|
||||
: run(),
|
||||
);
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
authQueueRef.current = next.catch(() => {});
|
||||
return next;
|
||||
}, []);
|
||||
const applyAuthentication = useCallback(
|
||||
(authentication: Authentication) => {
|
||||
const changedIdentity
|
||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
if (changedIdentity) {
|
||||
generationRef.current += 1;
|
||||
queryClient.removeQueries({ type: "inactive" });
|
||||
queryClient.getMutationCache().clear();
|
||||
void queryClient.resetQueries();
|
||||
}
|
||||
userRef.current = authentication.user;
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
const clearSession = useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
userRef.current = undefined;
|
||||
setUser(undefined);
|
||||
setAccessToken(undefined);
|
||||
accessTokenRef.current = undefined;
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
|
||||
const refresh = useCallback((): Promise<boolean> => {
|
||||
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
||||
const started = generationRef.current;
|
||||
const pending = serialize(async () => {
|
||||
if (started !== generationRef.current) return false;
|
||||
try {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (started !== generationRef.current) return false;
|
||||
if (!data) {
|
||||
clearSession();
|
||||
return false;
|
||||
}
|
||||
applyAuthentication(data);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
if (started === generationRef.current) clearSession();
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
}).finally(() => {
|
||||
if (refreshPromiseRef.current === pending) refreshPromiseRef.current = undefined;
|
||||
});
|
||||
refreshPromiseRef.current = pending;
|
||||
return pending;
|
||||
}, [applyAuthentication, clearSession, serialize]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().finally(() => setReady(true));
|
||||
}, [refresh]);
|
||||
|
||||
async function authenticate(
|
||||
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
clearSession();
|
||||
const started = generationRef.current;
|
||||
await serialize(async () => {
|
||||
const authentication = await request(path, email, password);
|
||||
if (started === generationRef.current) applyAuthentication(authentication);
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
clearSession();
|
||||
await serialize(async () => {
|
||||
await apiClient.POST("/api/v1/auth/logout");
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
configureAuthClient({
|
||||
accessToken: () => accessTokenRef.current,
|
||||
clear: clearSession,
|
||||
generation: () => generationRef.current,
|
||||
refresh,
|
||||
});
|
||||
});
|
||||
useEffect(() => () => configureAuthClient(), []);
|
||||
|
||||
return (
|
||||
<AuthContext
|
||||
value={{
|
||||
accessToken,
|
||||
login: (email, password) =>
|
||||
authenticate("/api/v1/auth/login", email, password),
|
||||
logout,
|
||||
ready,
|
||||
refresh,
|
||||
register: (email, password) =>
|
||||
authenticate("/api/v1/auth/register", email, password),
|
||||
user,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext>
|
||||
);
|
||||
return <AuthContext value={value}>{props.children}</AuthContext>;
|
||||
};
|
||||
|
||||
export { AuthProvider };
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||
|
||||
import { type User } from "./auth-context";
|
||||
|
||||
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"];
|
||||
|
||||
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 });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function useAuthProviderVm() {
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<undefined | User>(undefined);
|
||||
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
|
||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const generationRef = useRef(0);
|
||||
const userRef = useRef<undefined | User>(undefined);
|
||||
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
|
||||
|
||||
// Cookie-writing requests share ordering, including across tabs with Web Locks.
|
||||
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
const next = authQueueRef.current.then(async () =>
|
||||
"locks" in navigator
|
||||
? navigator.locks.request("wealth-ledger-refresh", run)
|
||||
: run(),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
authQueueRef.current = next.catch(() => {});
|
||||
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const applyAuthentication = useCallback(
|
||||
(authentication: Authentication) => {
|
||||
const changedIdentity
|
||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
|
||||
if (changedIdentity) {
|
||||
generationRef.current += 1;
|
||||
queryClient.removeQueries({ type: "inactive" });
|
||||
queryClient.getMutationCache().clear();
|
||||
void queryClient.resetQueries();
|
||||
}
|
||||
|
||||
userRef.current = authentication.user;
|
||||
setUser(authentication.user);
|
||||
setAccessToken(authentication.accessToken);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
userRef.current = undefined;
|
||||
setUser(undefined);
|
||||
setAccessToken(undefined);
|
||||
accessTokenRef.current = undefined;
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
|
||||
const refresh = useCallback((): Promise<boolean> => {
|
||||
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
||||
const started = generationRef.current;
|
||||
|
||||
const pending = serialize(async () => {
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
if (!data) {
|
||||
clearSession();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
applyAuthentication(data);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
if (started === generationRef.current) clearSession();
|
||||
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
}).finally(() => {
|
||||
if (refreshPromiseRef.current === pending)
|
||||
refreshPromiseRef.current = undefined;
|
||||
});
|
||||
|
||||
refreshPromiseRef.current = pending;
|
||||
|
||||
return pending;
|
||||
}, [applyAuthentication, clearSession, serialize]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().finally(() => setReady(true));
|
||||
}, [refresh]);
|
||||
|
||||
async function authenticate(
|
||||
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
clearSession();
|
||||
const started = generationRef.current;
|
||||
await serialize(async () => {
|
||||
const authentication = await request(path, email, password);
|
||||
if (started === generationRef.current)
|
||||
applyAuthentication(authentication);
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
clearSession();
|
||||
await serialize(async () => {
|
||||
await apiClient.POST("/api/v1/auth/logout");
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
configureAuthClient({
|
||||
accessToken: () => accessTokenRef.current,
|
||||
clear: clearSession,
|
||||
generation: () => generationRef.current,
|
||||
refresh,
|
||||
});
|
||||
});
|
||||
useEffect(() => () => configureAuthClient(), []);
|
||||
|
||||
const authContextValue = {
|
||||
accessToken,
|
||||
login: (email: string, password: string) =>
|
||||
authenticate("/api/v1/auth/login", email, password),
|
||||
logout,
|
||||
ready,
|
||||
refresh,
|
||||
register: (email: string, password: string) =>
|
||||
authenticate("/api/v1/auth/register", email, password),
|
||||
user,
|
||||
};
|
||||
|
||||
return authContextValue;
|
||||
}
|
||||
|
||||
export { useAuthProviderVm };
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type AuthFormValues,
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
} from "../../model/validation";
|
||||
} from "../../model/auth.schema";
|
||||
import classes from "./AuthForm.module.css";
|
||||
|
||||
type AuthFormProps = {
|
||||
@@ -23,26 +23,33 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
const { onSubmit, register } = props;
|
||||
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<null | string>(null);
|
||||
|
||||
const onSubmit2 = methods.handleSubmit(async (values) => {
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await onSubmit(values.email, values.password);
|
||||
}
|
||||
catch {
|
||||
setSubmitError(t("auth.requestError"));
|
||||
}
|
||||
});
|
||||
|
||||
const to = register ? "/login" : "/register";
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(async (values) => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await onSubmit(values.email, values.password);
|
||||
}
|
||||
catch {
|
||||
setSubmitError(t("auth.requestError"));
|
||||
}
|
||||
})}
|
||||
>
|
||||
<form onSubmit={onSubmit2}>
|
||||
<fieldset className={classes.fieldset} disabled={isSubmitting}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
@@ -50,11 +57,13 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
{...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")}
|
||||
@@ -62,15 +71,14 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
error={errors.passwordConfirmation?.message as string}
|
||||
/>
|
||||
)}
|
||||
|
||||
{submitError && <Alert color="red">{submitError}</Alert>}
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
{t(register ? "auth.register" : "auth.login")}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={register ? "/login" : "/register"}
|
||||
variant="subtle"
|
||||
>
|
||||
|
||||
<Button component={Link} to={to} variant="subtle">
|
||||
{t(register ? "auth.haveAccount" : "auth.noAccount")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -8,11 +8,13 @@ type AuthPageLayoutProps = PropsWithChildren<{ title: string }>;
|
||||
|
||||
const AuthPageLayout: FC<AuthPageLayoutProps> = (props) => {
|
||||
const { children, title } = props;
|
||||
|
||||
return (
|
||||
<main className={classes.page}>
|
||||
<Paper className={classes.card} p="xl" radius="lg" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Title order={1}>{title}</Title>
|
||||
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { useAuth } from "../model/auth-context";
|
||||
import { AuthForm } from "./AuthForm/AuthForm";
|
||||
|
||||
const LoginForm: FC = () => {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const returnTo
|
||||
= (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts";
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
onSubmit={async (email, password) => {
|
||||
await login(email, password);
|
||||
await navigate(returnTo, { replace: true });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoginForm };
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { AuthForm } from "../AuthForm/AuthForm";
|
||||
import { useLoginFormVm } from "./LoginForm.vm";
|
||||
|
||||
const LoginForm: FC = () => {
|
||||
const { onSubmit } = useLoginFormVm();
|
||||
|
||||
return <AuthForm onSubmit={onSubmit} />;
|
||||
};
|
||||
|
||||
export { LoginForm };
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { useAuth } from "../../model/auth-context";
|
||||
import { AuthForm } from "../AuthForm/AuthForm";
|
||||
|
||||
function useLoginFormVm() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const returnTo
|
||||
= (location.state as null
|
||||
| { returnTo?: string })?.returnTo ?? "/accounts";
|
||||
|
||||
const onSubmit: ComponentProps<typeof AuthForm>["onSubmit"] = async (
|
||||
email,
|
||||
password,
|
||||
) => {
|
||||
await login(email, password);
|
||||
await navigate(returnTo, { replace: true });
|
||||
};
|
||||
|
||||
return { onSubmit };
|
||||
}
|
||||
|
||||
export { useLoginFormVm };
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { useAuth } from "../model/auth-context";
|
||||
import { AuthForm } from "./AuthForm/AuthForm";
|
||||
|
||||
const RegisterForm: FC = () => {
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const returnTo
|
||||
= (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts";
|
||||
|
||||
return (
|
||||
<AuthForm
|
||||
onSubmit={async (email, password) => {
|
||||
await register(email, password);
|
||||
await navigate(returnTo, { replace: true });
|
||||
}}
|
||||
register
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { RegisterForm };
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { AuthForm } from "../AuthForm/AuthForm";
|
||||
import { useRegisterFormVm } from "./RegisterForm.vm";
|
||||
|
||||
const RegisterForm: FC = () => {
|
||||
const { onSubmit } = useRegisterFormVm();
|
||||
|
||||
return <AuthForm onSubmit={onSubmit} register />;
|
||||
};
|
||||
|
||||
export { RegisterForm };
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { useAuth } from "../../model/auth-context";
|
||||
import { AuthForm } from "../AuthForm/AuthForm";
|
||||
|
||||
function useRegisterFormVm() {
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const returnTo
|
||||
= (location.state as null
|
||||
| { returnTo?: string })?.returnTo ?? "/accounts";
|
||||
|
||||
const onSubmit: ComponentProps<typeof AuthForm>["onSubmit"] = async (
|
||||
email,
|
||||
password,
|
||||
) => {
|
||||
await register(email, password);
|
||||
await navigate(returnTo, { replace: true });
|
||||
};
|
||||
|
||||
return { onSubmit, register };
|
||||
}
|
||||
|
||||
export { useRegisterFormVm };
|
||||
Reference in New Issue
Block a user