Spaces:
Sleeping
Sleeping
| import axios, { AxiosError } from "axios"; | |
| import { inferRoleFromEmail, normalizeRole, type UserRole } from "@/lib/auth"; | |
| import { backendEndpointDocs } from "@/services/backendEndpoints"; | |
| type TokenGetter = () => string | null; | |
| export type UserAccountStatus = "active" | "inactive"; | |
| export type LoginResponse = { | |
| token: string; | |
| user: { | |
| id?: number; | |
| name?: string; | |
| email: string; | |
| role?: UserRole | string; | |
| status?: UserAccountStatus | string; | |
| }; | |
| }; | |
| export type UserProfile = { | |
| id: number; | |
| name: string; | |
| email: string; | |
| role?: UserRole | string; | |
| status?: UserAccountStatus | string; | |
| }; | |
| export type RegisterPayload = { | |
| fullName: string; | |
| employeeId: string; | |
| email: string; | |
| departmentBranch: string; | |
| phone: string; | |
| password: string; | |
| role: BackendRegisterRole; | |
| }; | |
| export type RegisterResponse = { | |
| message: string; | |
| }; | |
| export type BackendRegisterRole = "admin" | "student" | "lecturer"; | |
| let getAuthToken: TokenGetter = () => null; | |
| let runtimeAuthToken: string | null = null; | |
| export function bindTokenGetter(getter: TokenGetter) { | |
| getAuthToken = getter; | |
| } | |
| export function setRuntimeAuthToken(token: string | null) { | |
| runtimeAuthToken = token; | |
| } | |
| function resolveAuthToken() { | |
| return runtimeAuthToken ?? getAuthToken(); | |
| } | |
| function normalizeError(error: unknown): Error { | |
| if (error instanceof Error) return error; | |
| return new Error("Unknown API error"); | |
| } | |
| function isRecord(value: unknown): value is Record<string, unknown> { | |
| return typeof value === "object" && value !== null; | |
| } | |
| function readString(source: Record<string, unknown>, keys: string[]) { | |
| for (const key of keys) { | |
| const value = source[key]; | |
| if (typeof value === "string" && value.trim().length > 0) return value; | |
| } | |
| return undefined; | |
| } | |
| function resolvePayloadRoot(payload: unknown) { | |
| if (!isRecord(payload)) return {}; | |
| const data = payload.data; | |
| if (isRecord(data)) return data; | |
| return payload; | |
| } | |
| function normalizeUserStatus(value: unknown): UserAccountStatus | undefined { | |
| if (typeof value !== "string") return undefined; | |
| const lowered = value.trim().toLowerCase(); | |
| if (lowered === "active" || lowered === "inactive") return lowered; | |
| return undefined; | |
| } | |
| function toLoginResponse( | |
| payload: unknown, | |
| email: string, | |
| role?: UserRole, | |
| ): LoginResponse { | |
| const root = resolvePayloadRoot(payload); | |
| const token = | |
| readString(root, ["token", "access_token", "accessToken"]) ?? | |
| readString(isRecord(payload) ? payload : {}, [ | |
| "token", | |
| "access_token", | |
| "accessToken", | |
| ]); | |
| const userSource = isRecord(root.user) | |
| ? root.user | |
| : isRecord(payload) && isRecord(payload.user) | |
| ? payload.user | |
| : {}; | |
| const resolvedEmail = readString(userSource, ["email"]) ?? email; | |
| const resolvedRole = normalizeRole( | |
| readString(userSource, ["role"]) ?? | |
| role ?? | |
| inferRoleFromEmail(resolvedEmail), | |
| ); | |
| const resolvedStatus = normalizeUserStatus(userSource.status); | |
| if (resolvedStatus === "inactive") { | |
| throw new Error( | |
| "Akun belum diverifikasi. Silakan verifikasi email terlebih dahulu.", | |
| ); | |
| } | |
| if (!token) { | |
| throw new Error("Auth token missing from sign-in response"); | |
| } | |
| return { | |
| token, | |
| user: { | |
| id: typeof userSource.id === "number" ? userSource.id : undefined, | |
| name: readString(userSource, ["name"]), | |
| email: resolvedEmail, | |
| role: resolvedRole, | |
| status: resolvedStatus, | |
| }, | |
| }; | |
| } | |
| function toUserProfile(payload: unknown): UserProfile { | |
| const root = resolvePayloadRoot(payload); | |
| const fallback = isRecord(payload) ? payload : {}; | |
| const idValue = root.id ?? fallback.id; | |
| const id = | |
| typeof idValue === "number" | |
| ? idValue | |
| : typeof idValue === "string" && Number.isFinite(Number(idValue)) | |
| ? Number(idValue) | |
| : 0; | |
| const email = | |
| readString(root, ["email"]) ?? | |
| readString(fallback, ["email"]) ?? | |
| "unknown@example.com"; | |
| return { | |
| id, | |
| name: | |
| readString(root, ["name"]) ?? | |
| readString(fallback, ["name"]) ?? | |
| "Unknown User", | |
| email, | |
| role: | |
| readString(root, ["role"]) ?? | |
| readString(fallback, ["role"]) ?? | |
| inferRoleFromEmail(email), | |
| status: | |
| normalizeUserStatus(root.status) ?? | |
| normalizeUserStatus(fallback.status), | |
| }; | |
| } | |
| function toRegisterMessage(payload: unknown): string { | |
| const root = resolvePayloadRoot(payload); | |
| return ( | |
| readString(root, ["message", "detail", "status"]) ?? | |
| (isRecord(payload) | |
| ? readString(payload, ["message", "detail", "status"]) | |
| : undefined) ?? | |
| "Registrasi berhasil. Cek email untuk verifikasi akun." | |
| ); | |
| } | |
| function isDummyAuthEnabled() { | |
| const mode = import.meta.env.VITE_DUMMY_AUTH_MODE?.toLowerCase(); | |
| if (mode === "on" || mode === "true" || mode === "1") return true; | |
| if (mode === "off" || mode === "false" || mode === "0") return false; | |
| return import.meta.env.DEV; | |
| } | |
| function shouldUseDummyFallback(error: unknown) { | |
| if (!isDummyAuthEnabled()) return false; | |
| if (!(error instanceof Error)) return false; | |
| const message = error.message.toLowerCase(); | |
| return ( | |
| message.includes("status code 404") || | |
| message.includes("network error") || | |
| message.includes("failed to fetch") || | |
| message.includes("ecconnrefused") | |
| ); | |
| } | |
| function toText(value: unknown): string | undefined { | |
| if (typeof value === "string") { | |
| const trimmed = value.trim(); | |
| return trimmed.length > 0 ? trimmed : undefined; | |
| } | |
| if (typeof value === "number" || typeof value === "bigint") { | |
| return String(value); | |
| } | |
| return undefined; | |
| } | |
| function extractApiErrorMessage(payload: unknown): string | undefined { | |
| const directText = toText(payload); | |
| if (directText) return directText; | |
| if (!isRecord(payload)) return undefined; | |
| const direct = | |
| readString(payload, ["message", "detail", "error", "status"]) ?? | |
| undefined; | |
| if (direct) return direct; | |
| const detail = payload.detail; | |
| if (Array.isArray(detail)) { | |
| const parts = detail | |
| .map((item) => { | |
| if (typeof item === "string") return item; | |
| if (!isRecord(item)) return undefined; | |
| const msg = toText(item.msg) ?? toText(item.message); | |
| const loc = | |
| Array.isArray(item.loc) && item.loc.length > 0 | |
| ? item.loc | |
| .map((part) => toText(part) ?? "") | |
| .filter(Boolean) | |
| .join(".") | |
| : undefined; | |
| if (loc && msg) return `${loc}: ${msg}`; | |
| return msg; | |
| }) | |
| .filter((item): item is string => Boolean(item)); | |
| if (parts.length > 0) return parts.join("; "); | |
| } | |
| return undefined; | |
| } | |
| function resolveAuthEndpoint(kind: "signIn" | "signUp" | "me") { | |
| return backendEndpointDocs.auth[kind]; | |
| } | |
| export const api = axios.create({ | |
| baseURL: import.meta.env.VITE_API_URL ?? "/api/v1", | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }); | |
| api.interceptors.request.use((config) => { | |
| const token = resolveAuthToken(); | |
| if (!token) return config; | |
| config.headers = config.headers ?? {}; | |
| config.headers.Authorization = `Bearer ${token}`; | |
| return config; | |
| }); | |
| api.interceptors.response.use( | |
| (response) => response, | |
| (error: AxiosError<{ message?: string }>) => { | |
| const detailMessage = extractApiErrorMessage(error.response?.data); | |
| const backendMessage = | |
| detailMessage ?? | |
| error.response?.data?.message ?? | |
| error.message ?? | |
| "Request failed unexpectedly"; | |
| return Promise.reject(new Error(backendMessage)); | |
| }, | |
| ); | |
| export async function login(email: string, password: string, role?: UserRole) { | |
| try { | |
| const response = await api.post<unknown>( | |
| resolveAuthEndpoint("signIn"), | |
| { | |
| email, | |
| password, | |
| }, | |
| ); | |
| return toLoginResponse(response.data, email, role); | |
| } catch (error) { | |
| if (shouldUseDummyFallback(error)) { | |
| const resolvedRole = normalizeRole( | |
| role ?? inferRoleFromEmail(email), | |
| ); | |
| return { | |
| token: `dummy-token-${Date.now()}`, | |
| user: { | |
| id: 1, | |
| name: "Dummy User", | |
| email, | |
| role: resolvedRole, | |
| }, | |
| }; | |
| } | |
| throw error; | |
| } | |
| } | |
| export async function getProfile() { | |
| const response = await api.get<unknown>(resolveAuthEndpoint("me")); | |
| return toUserProfile(response.data); | |
| } | |
| export async function registerAccount( | |
| payload: RegisterPayload, | |
| ): Promise<RegisterResponse> { | |
| const body = { | |
| name: payload.fullName, | |
| email: payload.email, | |
| password: payload.password, | |
| identity_number: payload.employeeId, | |
| role: payload.role, | |
| }; | |
| const response = await api.post<unknown>( | |
| resolveAuthEndpoint("signUp"), | |
| body, | |
| ); | |
| return { message: toRegisterMessage(response.data) }; | |
| } | |
| export async function checkHealth() { | |
| try { | |
| const response = await api.get<{ status: string; service: string }>( | |
| "/health", | |
| ); | |
| return response.data; | |
| } catch (error) { | |
| throw normalizeError(error); | |
| } | |
| } | |