diff --git a/Frontend/erp-system/app/login/page.tsx b/Frontend/erp-system/app/login/page.tsx index 59b6817..650f499 100644 --- a/Frontend/erp-system/app/login/page.tsx +++ b/Frontend/erp-system/app/login/page.tsx @@ -6,8 +6,11 @@ import Link from "next/link" import { useRouter } from "next/navigation" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { Eye, EyeOff } from "lucide-react" +import { AlertCircle, Eye, EyeOff } from "lucide-react" import { loginSchema, LoginValues } from "@/lib/validations" +import { loginUser } from "@/lib/api/auth" +import { AuthApiError } from "@/lib/api/http" +import { persistSession } from "@/lib/auth/session" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -41,6 +44,7 @@ export default function LoginPage() { const router = useRouter() const [showPassword, setShowPassword] = useState(false) const [remember, setRemember] = useState(false) + const [authError, setAuthError] = useState(null) const form = useForm({ resolver: zodResolver(loginSchema), @@ -48,8 +52,14 @@ export default function LoginPage() { }) const onSubmit = form.handleSubmit(async (values) => { - console.log(values) - router.push("/dashboard") + setAuthError(null) + try { + const result = await loginUser({ identifier: values.email, password: values.password }) + persistSession(result, remember) + router.push("/dashboard") + } catch (err) { + setAuthError(err instanceof AuthApiError ? err.message : "Something went wrong. Please try again.") + } }) return ( @@ -79,6 +89,13 @@ export default function LoginPage() {
+ {authError && ( +
+ + {authError} +
+ )} + diff --git a/Frontend/erp-system/lib/api/auth.ts b/Frontend/erp-system/lib/api/auth.ts new file mode 100644 index 0000000..ec5b8c7 --- /dev/null +++ b/Frontend/erp-system/lib/api/auth.ts @@ -0,0 +1,30 @@ +import { callAuthFunction } from "@/lib/api/http" + +export interface AuthUser { + id: string + fullName: string | null + userName: string | null + email: string | null + mobileNumber: string | null + emailVerified: boolean + mobileNumberVerified: boolean + isMfaEnabled: boolean + roleId: string | null + userTypeId: string | null +} + +export interface LoginResult { + accessToken: string + refreshToken: string + expiresIn: number + user: AuthUser +} + +/** POST /api/loginUser (forced functionName "loginUser" server-side). */ +export function loginUser(params: { identifier: string; password: string; deviceName?: string }) { + return callAuthFunction("/api/loginUser", "loginUser", { + identifier: params.identifier, + password: params.password, + deviceName: params.deviceName ?? (typeof navigator !== "undefined" ? navigator.userAgent : "Unknown Device"), + }) +} diff --git a/Frontend/erp-system/lib/api/http.ts b/Frontend/erp-system/lib/api/http.ts new file mode 100644 index 0000000..1d39669 --- /dev/null +++ b/Frontend/erp-system/lib/api/http.ts @@ -0,0 +1,67 @@ +// Thin client for the AuthHex service's function-dispatch envelope +// (POST { functionName, payload, reference } -> { statusCode, success, message, data }). +// See D:\HexDive\ERP_Auth_Service\API_DOCUMENTATION.md for the full contract. + +const AUTH_API_BASE_URL = + process.env.NEXT_PUBLIC_AUTH_API_BASE_URL?.replace(/\/$/, "") ?? "https://localhost:7111" + +interface ApiEnvelope { + statusCode: number + success: boolean + message: string | null + data: T | null +} + +export class AuthApiError extends Error { + statusCode: number + constructor(message: string, statusCode: number) { + super(message) + this.name = "AuthApiError" + this.statusCode = statusCode + } +} + +function makeReference() { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `req-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +/** + * Calls one function on an AuthHex route (e.g. POST /api/loginUser). + * The service's own `success` flag is not reliable on error paths (it defaults + * to true even when statusCode is 4xx/5xx), so failure is judged from + * `!response.ok || body.statusCode !== 200`, not from `body.success`. + */ +export async function callAuthFunction( + route: string, + functionName: string, + payload: Record = {} +): Promise { + let response: Response + try { + response = await fetch(`${AUTH_API_BASE_URL}${route}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ functionName, payload, reference: makeReference() }), + }) + } catch { + throw new AuthApiError( + "Could not reach the authentication service. Check your connection and try again.", + 0 + ) + } + + let body: ApiEnvelope | null = null + try { + body = (await response.json()) as ApiEnvelope + } catch { + // non-JSON body — body stays null, handled below + } + + if (!response.ok || !body || body.statusCode !== 200) { + throw new AuthApiError(body?.message || `Request failed (${response.status})`, body?.statusCode ?? response.status) + } + + return body.data as T +} diff --git a/Frontend/erp-system/lib/auth/session.ts b/Frontend/erp-system/lib/auth/session.ts new file mode 100644 index 0000000..6a62cb3 --- /dev/null +++ b/Frontend/erp-system/lib/auth/session.ts @@ -0,0 +1,39 @@ +import type { AuthUser, LoginResult } from "@/lib/api/auth" + +const ACCESS_TOKEN_KEY = "hexa_erp_access_token" +const REFRESH_TOKEN_KEY = "hexa_erp_refresh_token" +const USER_KEY = "hexa_erp_user" + +/** remember=true persists across browser restarts (localStorage); otherwise session-only. */ +export function persistSession(result: LoginResult, remember: boolean) { + const store = remember ? window.localStorage : window.sessionStorage + const other = remember ? window.sessionStorage : window.localStorage + for (const key of [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY]) other.removeItem(key) + + store.setItem(ACCESS_TOKEN_KEY, result.accessToken) + store.setItem(REFRESH_TOKEN_KEY, result.refreshToken) + store.setItem(USER_KEY, JSON.stringify(result.user)) +} + +export function getAccessToken(): string | null { + if (typeof window === "undefined") return null + return window.localStorage.getItem(ACCESS_TOKEN_KEY) ?? window.sessionStorage.getItem(ACCESS_TOKEN_KEY) +} + +export function getStoredUser(): AuthUser | null { + if (typeof window === "undefined") return null + const raw = window.localStorage.getItem(USER_KEY) ?? window.sessionStorage.getItem(USER_KEY) + if (!raw) return null + try { + return JSON.parse(raw) as AuthUser + } catch { + return null + } +} + +export function clearSession() { + for (const key of [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY]) { + window.localStorage.removeItem(key) + window.sessionStorage.removeItem(key) + } +} diff --git a/Frontend/erp-system/lib/validations.ts b/Frontend/erp-system/lib/validations.ts index 04304b7..e4520e0 100644 --- a/Frontend/erp-system/lib/validations.ts +++ b/Frontend/erp-system/lib/validations.ts @@ -15,21 +15,14 @@ export const emailSchema = z.preprocess( z.string().min(1, "Email is required").email("Enter a valid email") ) -// Login schema (email + password) for reuse across the app +// Login schema (email + password) for reuse across the app. +// Password strength is intentionally NOT re-validated here: an existing +// account's password may predate current complexity rules, and only the +// server can judge whether credentials are actually correct. Complexity +// rules belong on registration/reset-password, not on login. export const loginSchema = z.object({ email: emailSchema, - password: requiredString("Password is required") - .refine((val) => val.length >= 8, { message: "Password must be at least 8 characters" }) - .refine((val) => /[A-Z]/.test(val), { - message: "Password must contain at least one uppercase letter", - }) - .refine((val) => /[a-z]/.test(val), { - message: "Password must contain at least one lowercase letter", - }) - .refine((val) => /[0-9]/.test(val), { message: "Password must contain at least one number" }) - .refine((val) => /[!@#$%^&*(),.?":{}|<>\[\]\\/`~;'+=-]/.test(val), { - message: "Password must contain at least one special character", - }), + password: requiredString("Password is required"), }) export type LoginValues = z.infer