mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 14:42:05 +00:00
feat: add auth domain, provider and login page
This commit is contained in:
parent
ddc174c204
commit
7967f7e4c5
17 changed files with 504 additions and 0 deletions
21
src/app/(auth)/_components/auth-card-header.tsx
Normal file
21
src/app/(auth)/_components/auth-card-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal file
34
src/app/(auth)/_components/auth-page-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/app/(auth)/_components/login-form.tsx
Normal file
83
src/app/(auth)/_components/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/_layout.tsx
Normal file
11
src/app/(auth)/_layout.tsx
Normal 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
43
src/app/(auth)/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/auth/guest-route.tsx
Normal file
29
src/components/auth/guest-route.tsx
Normal 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;
|
||||
}
|
||||
29
src/components/auth/protected-route.tsx
Normal file
29
src/components/auth/protected-route.tsx
Normal 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;
|
||||
}
|
||||
59
src/domain/auth/api/auth-api.ts
Normal file
59
src/domain/auth/api/auth-api.ts
Normal 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);
|
||||
}
|
||||
8
src/domain/auth/schemas/login-schema.ts
Normal file
8
src/domain/auth/schemas/login-schema.ts
Normal 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>;
|
||||
26
src/domain/auth/types/auth-user.ts
Normal file
26
src/domain/auth/types/auth-user.ts
Normal 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;
|
||||
}
|
||||
16
src/domain/auth/use-cases/use-change-password.ts
Normal file
16
src/domain/auth/use-cases/use-change-password.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
56
src/domain/auth/use-cases/use-login.ts
Normal file
56
src/domain/auth/use-cases/use-login.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
1
src/domain/auth/use-cases/use-logout.ts
Normal file
1
src/domain/auth/use-cases/use-logout.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useLogout } from "@/domain/auth/use-cases/use-login";
|
||||
1
src/domain/auth/use-cases/use-session.ts
Normal file
1
src/domain/auth/use-cases/use-session.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useSession } from "@/domain/auth/use-cases/use-login";
|
||||
20
src/providers/AppProviders.tsx
Normal file
20
src/providers/AppProviders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/providers/auth-context.ts
Normal file
30
src/providers/auth-context.ts
Normal 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;
|
||||
}
|
||||
37
src/providers/auth-provider.tsx
Normal file
37
src/providers/auth-provider.tsx
Normal 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>;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue