Compare commits
1 Commits
c4e016c460
...
login-api
| Author | SHA1 | Date | |
|---|---|---|---|
| 36302ff564 |
@@ -6,8 +6,11 @@ import Link from "next/link"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useForm } from "react-hook-form"
|
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 { 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 { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@@ -41,6 +44,7 @@ export default function LoginPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [remember, setRemember] = useState(false)
|
const [remember, setRemember] = useState(false)
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null)
|
||||||
|
|
||||||
const form = useForm<LoginValues>({
|
const form = useForm<LoginValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
@@ -48,8 +52,14 @@ export default function LoginPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
console.log(values)
|
setAuthError(null)
|
||||||
|
try {
|
||||||
|
const result = await loginUser({ identifier: values.email, password: values.password })
|
||||||
|
persistSession(result, remember)
|
||||||
router.push("/dashboard")
|
router.push("/dashboard")
|
||||||
|
} catch (err) {
|
||||||
|
setAuthError(err instanceof AuthApiError ? err.message : "Something went wrong. Please try again.")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,6 +89,13 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
|
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
|
||||||
|
{authError && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{authError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<FieldGroup className="space-y-5">
|
<FieldGroup className="space-y-5">
|
||||||
<Field data-invalid={!!form.formState.errors.email}>
|
<Field data-invalid={!!form.formState.errors.email}>
|
||||||
<FieldLabel htmlFor="email" className="text-sm font-semibold text-foreground">
|
<FieldLabel htmlFor="email" className="text-sm font-semibold text-foreground">
|
||||||
|
|||||||
@@ -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<LoginResult>("/api/loginUser", "loginUser", {
|
||||||
|
identifier: params.identifier,
|
||||||
|
password: params.password,
|
||||||
|
deviceName: params.deviceName ?? (typeof navigator !== "undefined" ? navigator.userAgent : "Unknown Device"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
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<T>(
|
||||||
|
route: string,
|
||||||
|
functionName: string,
|
||||||
|
payload: Record<string, unknown> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
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<T> | null = null
|
||||||
|
try {
|
||||||
|
body = (await response.json()) as ApiEnvelope<T>
|
||||||
|
} 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,21 +15,14 @@ export const emailSchema = z.preprocess(
|
|||||||
z.string().min(1, "Email is required").email("Enter a valid email")
|
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({
|
export const loginSchema = z.object({
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: requiredString("Password is required")
|
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",
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type LoginValues = z.infer<typeof loginSchema>
|
export type LoginValues = z.infer<typeof loginSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user