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 (
+
+ );
+}
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};
+}