From 7967f7e4c576d4bad7b01ce1028803048a61bab4 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 12 Jun 2026 11:52:16 -0300 Subject: [PATCH] feat: add auth domain, provider and login page --- .../(auth)/_components/auth-card-header.tsx | 21 +++++ .../(auth)/_components/auth-page-shell.tsx | 34 ++++++++ src/app/(auth)/_components/login-form.tsx | 83 +++++++++++++++++++ src/app/(auth)/_layout.tsx | 11 +++ src/app/(auth)/login.tsx | 43 ++++++++++ src/components/auth/guest-route.tsx | 29 +++++++ src/components/auth/protected-route.tsx | 29 +++++++ src/domain/auth/api/auth-api.ts | 59 +++++++++++++ src/domain/auth/schemas/login-schema.ts | 8 ++ src/domain/auth/types/auth-user.ts | 26 ++++++ .../auth/use-cases/use-change-password.ts | 16 ++++ src/domain/auth/use-cases/use-login.ts | 56 +++++++++++++ src/domain/auth/use-cases/use-logout.ts | 1 + src/domain/auth/use-cases/use-session.ts | 1 + src/providers/AppProviders.tsx | 20 +++++ src/providers/auth-context.ts | 30 +++++++ src/providers/auth-provider.tsx | 37 +++++++++ 17 files changed, 504 insertions(+) create mode 100644 src/app/(auth)/_components/auth-card-header.tsx create mode 100644 src/app/(auth)/_components/auth-page-shell.tsx create mode 100644 src/app/(auth)/_components/login-form.tsx create mode 100644 src/app/(auth)/_layout.tsx create mode 100644 src/app/(auth)/login.tsx create mode 100644 src/components/auth/guest-route.tsx create mode 100644 src/components/auth/protected-route.tsx create mode 100644 src/domain/auth/api/auth-api.ts create mode 100644 src/domain/auth/schemas/login-schema.ts create mode 100644 src/domain/auth/types/auth-user.ts create mode 100644 src/domain/auth/use-cases/use-change-password.ts create mode 100644 src/domain/auth/use-cases/use-login.ts create mode 100644 src/domain/auth/use-cases/use-logout.ts create mode 100644 src/domain/auth/use-cases/use-session.ts create mode 100644 src/providers/AppProviders.tsx create mode 100644 src/providers/auth-context.ts create mode 100644 src/providers/auth-provider.tsx diff --git a/src/app/(auth)/_components/auth-card-header.tsx b/src/app/(auth)/_components/auth-card-header.tsx new file mode 100644 index 00000000..795ca0a7 --- /dev/null +++ b/src/app/(auth)/_components/auth-card-header.tsx @@ -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 ( +
+

{title}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ); +} diff --git a/src/app/(auth)/_components/auth-page-shell.tsx b/src/app/(auth)/_components/auth-page-shell.tsx new file mode 100644 index 00000000..b5dca71e --- /dev/null +++ b/src/app/(auth)/_components/auth-page-shell.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/(auth)/_components/login-form.tsx b/src/app/(auth)/_components/login-form.tsx new file mode 100644 index 00000000..4533576b --- /dev/null +++ b/src/app/(auth)/_components/login-form.tsx @@ -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; + handleSubmit: UseFormHandleSubmit; + errors: FieldErrors; + onSubmit: (values: LoginFormValues) => void; + isLoggingIn: boolean; + loginError: Error | null; +}; + +export function LoginForm({ + register, + handleSubmit, + errors, + onSubmit, + isLoggingIn, + loginError, +}: LoginFormProps) { + return ( + + + + + + + + {loginError && } + + + + + ); +} diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx new file mode 100644 index 00000000..916865d7 --- /dev/null +++ b/src/app/(auth)/_layout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from "react-router-dom"; + +import { GuestRoute } from "@/components/auth/guest-route"; + +export default function AuthLayout() { + return ( + + + + ); +} diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx new file mode 100644 index 00000000..0047bc06 --- /dev/null +++ b/src/app/(auth)/login.tsx @@ -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({ + resolver: zodResolver(loginSchema), + defaultValues: { + username: "", + password: "", + }, + }); + + return ( + + + + +

+ Having trouble? Contact your administrator. +

+
+ ); +} diff --git a/src/components/auth/guest-route.tsx b/src/components/auth/guest-route.tsx new file mode 100644 index 00000000..d44cc2d7 --- /dev/null +++ b/src/components/auth/guest-route.tsx @@ -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 ( + + + + ); + } + + if (isAuthenticated) { + return ; + } + + return children; +} diff --git a/src/components/auth/protected-route.tsx b/src/components/auth/protected-route.tsx new file mode 100644 index 00000000..92389674 --- /dev/null +++ b/src/components/auth/protected-route.tsx @@ -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 ( + + + + ); + } + + if (!isAuthenticated) { + return ; + } + + return children; +} diff --git a/src/domain/auth/api/auth-api.ts b/src/domain/auth/api/auth-api.ts new file mode 100644 index 00000000..0ec9a825 --- /dev/null +++ b/src/domain/auth/api/auth-api.ts @@ -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 { + const response = await apiPost(API_PATHS.authentication.login, credentials); + return handleApiResponse(response); +} + +export async function logout(): Promise { + await apiPost(API_PATHS.authentication.logout); +} + +export async function getSession(): Promise { + 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(payload); + return user ?? null; + } catch { + return null; + } +} + +export async function changePassword(data: ChangePasswordPayload): Promise { + return apiPost(API_PATHS.authentication.changePassword, data); +} + +export async function forgotPassword(email: string): Promise { + return apiPost(`${API_PATHS.authentication.forgetPassword}?Email=${encodeURIComponent(email)}`); +} + +export async function verifyResetCode(code: string): Promise { + return apiPost(`${API_PATHS.authentication.verificationCode}?code=${encodeURIComponent(code)}`); +} + +export async function resetPassword(data: ResetPasswordPayload): Promise { + return apiPost(API_PATHS.authentication.resetPassword, data); +} diff --git a/src/domain/auth/schemas/login-schema.ts b/src/domain/auth/schemas/login-schema.ts new file mode 100644 index 00000000..6f85ca45 --- /dev/null +++ b/src/domain/auth/schemas/login-schema.ts @@ -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; diff --git a/src/domain/auth/types/auth-user.ts b/src/domain/auth/types/auth-user.ts new file mode 100644 index 00000000..22a94c76 --- /dev/null +++ b/src/domain/auth/types/auth-user.ts @@ -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; +} diff --git a/src/domain/auth/use-cases/use-change-password.ts b/src/domain/auth/use-cases/use-change-password.ts new file mode 100644 index 00000000..eec15235 --- /dev/null +++ b/src/domain/auth/use-cases/use-change-password.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/auth/use-cases/use-login.ts b/src/domain/auth/use-cases/use-login.ts new file mode 100644 index 00000000..b677e9ae --- /dev/null +++ b/src/domain/auth/use-cases/use-login.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.auth.session(), + queryFn: authApi.getSession, + retry: false, + staleTime: 5 * 60 * 1000, + }); +} + +export function useLogin(): UseMutationResult { + 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 { + 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"); + }, + }); +} diff --git a/src/domain/auth/use-cases/use-logout.ts b/src/domain/auth/use-cases/use-logout.ts new file mode 100644 index 00000000..19a74077 --- /dev/null +++ b/src/domain/auth/use-cases/use-logout.ts @@ -0,0 +1 @@ +export { useLogout } from "@/domain/auth/use-cases/use-login"; diff --git a/src/domain/auth/use-cases/use-session.ts b/src/domain/auth/use-cases/use-session.ts new file mode 100644 index 00000000..981f1e06 --- /dev/null +++ b/src/domain/auth/use-cases/use-session.ts @@ -0,0 +1 @@ +export { useSession } from "@/domain/auth/use-cases/use-login"; diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx new file mode 100644 index 00000000..af480bcb --- /dev/null +++ b/src/providers/AppProviders.tsx @@ -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 ( + + + + {children} + + {import.meta.env.DEV && } + + + ); +} diff --git a/src/providers/auth-context.ts b/src/providers/auth-context.ts new file mode 100644 index 00000000..f8907727 --- /dev/null +++ b/src/providers/auth-context.ts @@ -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(null); + +export function useAuthContext(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuthContext must be used within AuthProvider"); + } + return context; +} diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx new file mode 100644 index 00000000..36bd571d --- /dev/null +++ b/src/providers/auth-provider.tsx @@ -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( + () => ({ + 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 {children}; +}