feat: add auth domain, provider and login page

This commit is contained in:
Arthur Bassi 2026-06-12 11:52:16 -03:00
parent ddc174c204
commit 7967f7e4c5
17 changed files with 504 additions and 0 deletions

View file

@ -0,0 +1,21 @@
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
title: string;
subtitle?: string;
};
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return (
<div className={cn(className)} {...props}>
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
{subtitle && (
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
{subtitle}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,34 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type AuthPageShellProps = ComponentPropsWithoutRef<"div"> & {
cardClassName?: string;
children: ReactNode;
};
export function AuthPageShell({
children,
className,
cardClassName,
...props
}: AuthPageShellProps) {
return (
<div
className={cn(
"flex min-h-screen w-full items-center justify-center bg-(--color-bg-page) px-4",
className,
)}
{...props}
>
<div
className={cn(
"w-full max-w-sm rounded-(--radius-lg) border border-border bg-card p-10! shadow-sm",
cardClassName,
)}
>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,83 @@
import type { FieldErrors, UseFormHandleSubmit, UseFormRegister } from "react-hook-form";
import { Box } from "@mui/material";
import { Button } from "@/components/ui/button";
import { FormErrorMessage } from "@/components/ui/form-error-message";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { TextField } from "@/components/ui/text-field";
import type { LoginFormValues } from "@/domain/auth/schemas/login-schema";
export type LoginFormProps = {
register: UseFormRegister<LoginFormValues>;
handleSubmit: UseFormHandleSubmit<LoginFormValues>;
errors: FieldErrors<LoginFormValues>;
onSubmit: (values: LoginFormValues) => void;
isLoggingIn: boolean;
loginError: Error | null;
};
export function LoginForm({
register,
handleSubmit,
errors,
onSubmit,
isLoggingIn,
loginError,
}: LoginFormProps) {
return (
<Box
component="form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
autoComplete="off"
>
<FormField
label="Email"
htmlFor="login-email"
error={Boolean(errors.username)}
helperText={errors.username?.message}
appearance="auth"
>
<TextField
{...register("username")}
id="login-email"
type="email"
autoComplete="email"
placeholder="you@seahavenind.com"
disabled={isLoggingIn}
error={Boolean(errors.username)}
/>
</FormField>
<FormField
label="Password"
htmlFor="login-password"
error={Boolean(errors.password)}
helperText={errors.password?.message}
appearance="auth"
>
<PasswordField
{...register("password")}
id="login-password"
autoComplete="current-password"
placeholder="••••••••"
disabled={isLoggingIn}
error={Boolean(errors.password)}
/>
{loginError && <FormErrorMessage message="Invalid email or password. Please try again." />}
</FormField>
<Button
type="submit"
fullWidth
size="lg"
loading={isLoggingIn}
loadingText="Signing in…"
className="mt-1!"
>
Sign In
</Button>
</Box>
);
}

View file

@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
import { GuestRoute } from "@/components/auth/guest-route";
export default function AuthLayout() {
return (
<GuestRoute>
<Outlet />
</GuestRoute>
);
}

43
src/app/(auth)/login.tsx Normal file
View file

@ -0,0 +1,43 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
export default function LoginPage() {
const { login, isLoggingIn, loginError } = useAuthContext();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
return (
<AuthPageShell>
<BrandLockup className="mb-8" />
<AuthCardHeader title="Welcome back" subtitle="Sign in to your account" />
<LoginForm
register={register}
handleSubmit={handleSubmit}
errors={errors}
onSubmit={login}
isLoggingIn={isLoggingIn}
loginError={loginError}
/>
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
Having trouble? Contact your administrator.
</p>
</AuthPageShell>
);
}

View file

@ -0,0 +1,29 @@
import { Navigate } from "react-router-dom";
import { useAuthContext } from "@/providers/auth-context";
import { Box, CircularProgress } from "@mui/material";
interface GuestRouteProps {
children: React.ReactNode;
}
export function GuestRoute({ children }: GuestRouteProps) {
const { isAuthenticated, isLoading } = useAuthContext();
if (isLoading) {
return (
<Box
className="flex min-h-screen items-center justify-center"
role="status"
aria-label="Loading"
>
<CircularProgress />
</Box>
);
}
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
return children;
}

View file

@ -0,0 +1,29 @@
import { Navigate } from "react-router-dom";
import { Box, CircularProgress } from "@mui/material";
import { useAuthContext } from "@/providers/auth-context";
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuthContext();
if (isLoading) {
return (
<Box
className="flex min-h-screen items-center justify-center"
role="status"
aria-label="Loading"
>
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}

View file

@ -0,0 +1,59 @@
/**
* Auth API contract (cookie-based session):
* - POST Authentication/Login — sets httpOnly session cookies
* - GET Authentication/GetCurrentUser — returns active user from cookie
* - POST Authentication/Logout — clears session cookies
* - POST Authentication/Refresh — refreshes session cookie on 401
*/
import { API_PATHS } from "@/api/api-paths";
import { api, apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import type {
AuthUser,
ChangePasswordPayload,
LoginCredentials,
ResetPasswordPayload,
} from "@/domain/auth/types/auth-user";
export async function login(credentials: LoginCredentials): Promise<AuthUser> {
const response = await apiPost<unknown>(API_PATHS.authentication.login, credentials);
return handleApiResponse<AuthUser>(response);
}
export async function logout(): Promise<void> {
await apiPost(API_PATHS.authentication.logout);
}
export async function getSession(): Promise<AuthUser | null> {
try {
const response = await api.get(API_PATHS.authentication.getCurrentUser, {
throwHttpErrors: false,
});
if (!response.ok) {
return null;
}
const payload = (await response.json()) as unknown;
const user = handleApiResponse<AuthUser | null>(payload);
return user ?? null;
} catch {
return null;
}
}
export async function changePassword(data: ChangePasswordPayload): Promise<unknown> {
return apiPost(API_PATHS.authentication.changePassword, data);
}
export async function forgotPassword(email: string): Promise<unknown> {
return apiPost(`${API_PATHS.authentication.forgetPassword}?Email=${encodeURIComponent(email)}`);
}
export async function verifyResetCode(code: string): Promise<unknown> {
return apiPost(`${API_PATHS.authentication.verificationCode}?code=${encodeURIComponent(code)}`);
}
export async function resetPassword(data: ResetPasswordPayload): Promise<unknown> {
return apiPost(API_PATHS.authentication.resetPassword, data);
}

View file

@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
username: z.string().min(1, "Email is required"),
password: z.string().min(1, "Password is required"),
});
export type LoginFormValues = z.infer<typeof loginSchema>;

View file

@ -0,0 +1,26 @@
export interface AuthUser {
id?: string | number;
name?: string;
email?: string;
username?: string;
role?: string;
roles?: string[];
[key: string]: unknown;
}
export interface LoginCredentials {
username: string;
password: string;
}
export interface ChangePasswordPayload {
Currentpassword: string;
Newpassword: string;
Confirmpassword: string;
}
export interface ResetPasswordPayload {
Email: string;
Code: string;
Password: string;
}

View file

@ -0,0 +1,16 @@
import { useMutation, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
return useMutation({
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
onSuccess: () => {
toast.success("Password changed successfully!");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to change password");
},
});
}

View file

@ -0,0 +1,56 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import * as authApi from "@/domain/auth/api/auth-api";
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
import { queryKeys } from "@/infra/query-key/query-key";
export function useSession(): UseQueryResult<AuthUser | null, Error> {
return useQuery({
queryKey: queryKeys.auth.session(),
queryFn: authApi.getSession,
retry: false,
staleTime: 5 * 60 * 1000,
});
}
export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true },
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session() });
toast.success("Login successful!");
navigate("/");
},
});
}
export function useLogout(): UseMutationResult<void, Error, void> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: authApi.logout,
onSuccess: () => {
queryClient.setQueryData(queryKeys.auth.session(), null);
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
toast.info("You have been logged out");
navigate("/login");
},
onError: (error: Error) => {
queryClient.setQueryData(queryKeys.auth.session(), null);
toast.error(error.message || "Logout failed");
navigate("/login");
},
});
}

View file

@ -0,0 +1 @@
export { useLogout } from "@/domain/auth/use-cases/use-login";

View file

@ -0,0 +1 @@
export { useSession } from "@/domain/auth/use-cases/use-login";

View file

@ -0,0 +1,20 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { CssBaseline, ThemeProvider } from "@mui/material";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { queryClient } from "@/lib/query/query-client";
import { muiTheme } from "@/lib/theme/mui-theme";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={muiTheme}>
<CssBaseline />
{children}
<ToastContainer />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</ThemeProvider>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,30 @@
import { createContext, useContext } from "react";
import type {
AuthUser,
ChangePasswordPayload,
LoginCredentials,
} from "@/domain/auth/types/auth-user";
export interface AuthContextValue {
user: AuthUser | null | undefined;
isAuthenticated: boolean;
isLoading: boolean;
error: Error | null;
login: (credentials: LoginCredentials) => void;
logout: () => void;
changePassword: (data: ChangePasswordPayload) => void;
isLoggingIn: boolean;
isLoggingOut: boolean;
isChangingPassword: boolean;
loginError: Error | null;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuthContext(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuthContext must be used within AuthProvider");
}
return context;
}

View file

@ -0,0 +1,37 @@
import { useMemo, type ReactNode } from "react";
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
export function AuthProvider({ children }: { children: ReactNode }) {
const sessionQuery = useSession();
const loginMutation = useLogin();
const logoutMutation = useLogout();
const changePasswordMutation = useChangePassword();
const value = useMemo<AuthContextValue>(
() => ({
user: sessionQuery.data,
isAuthenticated: Boolean(sessionQuery.data),
isLoading: sessionQuery.isLoading,
error: sessionQuery.error,
login: (credentials) => loginMutation.mutate(credentials),
logout: () => logoutMutation.mutate(),
changePassword: (data) => changePasswordMutation.mutate(data),
isLoggingIn: loginMutation.isPending,
isLoggingOut: logoutMutation.isPending,
isChangingPassword: changePasswordMutation.isPending,
loginError: loginMutation.error,
}),
[
sessionQuery.data,
sessionQuery.isLoading,
sessionQuery.error,
loginMutation,
logoutMutation,
changePasswordMutation,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}