Files

279 lines
7.8 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { ApiError, getAccessToken, isAbortError, migrateTokensToSharedCookies } from "@/lib/meshkee-api";
import {
getAdminDashboardOrigin,
getCustomerDashboardOrigin,
hostAppFor,
type HostApp,
} from "@/lib/host";
import {
clearManagedBusinessId,
getManagedBusinessId,
setManagedBusinessId,
} from "@/lib/managed-business";
import {
fetchCurrentUser,
login as loginRequest,
logout as logoutRequest,
register as registerRequest,
type RegisterPayload,
} from "@/services/auth";
import type { AuthUser, DashboardType } from "@/types/auth";
interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
activeBusinessId: string | null;
isBusinessOwner: boolean;
isSuperAdmin: boolean;
login: (cellNumber: string, password: string) => Promise<DashboardType>;
register: (payload: RegisterPayload) => Promise<DashboardType>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function pickActiveBusinessId(user: AuthUser): string | null {
const owned = user.businesses.find((item) => item.isOwner);
const first = owned ?? user.businesses[0];
return first ? String(first.id) : null;
}
export function userCanAccessApp(user: AuthUser, app: HostApp): boolean {
if (app === "admin") return user.dashboard === "super_admin";
if (app === "customer") {
// Shared entry: customer, business owners, and superadmins all land here first.
return (
user.dashboard === "customer" ||
user.dashboard === "business" ||
user.dashboard === "super_admin"
);
}
// Business dashboard: owners/staff, or superadmin (via managed business id).
if (user.dashboard === "super_admin") return true;
return Boolean(pickActiveBusinessId(user));
}
function currentHost(): string {
if (typeof window === "undefined") return "";
return window.location.host;
}
/** Prefer ?businessId= on the URL, then stored managed id. */
function syncManagedBusinessFromUrl() {
if (typeof window === "undefined") return getManagedBusinessId();
const fromUrl = new URLSearchParams(window.location.search)
.get("businessId")
?.trim();
if (fromUrl) {
setManagedBusinessId(fromUrl);
return fromUrl;
}
return getManagedBusinessId();
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [managedBusinessId, setManagedBusinessIdState] = useState<string | null>(
null,
);
const logout = useCallback(() => {
logoutRequest();
clearManagedBusinessId();
setManagedBusinessIdState(null);
setUser(null);
}, []);
useEffect(() => {
const controller = new AbortController();
async function init() {
// Handoff installs tokens via inline script and redirects — don't race it.
if (
typeof window !== "undefined" &&
window.location.pathname === "/auth/handoff"
) {
if (!controller.signal.aborted) setIsLoading(false);
return;
}
migrateTokensToSharedCookies();
setManagedBusinessIdState(syncManagedBusinessFromUrl());
if (!getAccessToken()) {
if (!controller.signal.aborted) setIsLoading(false);
return;
}
try {
const { user: currentUser } = await fetchCurrentUser(controller.signal);
if (controller.signal.aborted) return;
const app = hostAppFor(currentHost());
if (!userCanAccessApp(currentUser, app)) {
logout();
return;
}
const managed = syncManagedBusinessFromUrl();
setManagedBusinessIdState(managed);
setUser(currentUser);
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return;
// On business host, keep tokens so a transient /me failure doesn't
// bounce manage-business back to admin.
if (hostAppFor(currentHost()) === "business") {
if (!controller.signal.aborted) setIsLoading(false);
return;
}
logout();
} finally {
if (!controller.signal.aborted) setIsLoading(false);
}
}
void init();
return () => controller.abort();
}, [logout]);
const login = useCallback(async (cellNumber: string, password: string) => {
const data = await loginRequest(cellNumber, password);
const app = hostAppFor(currentHost());
if (!userCanAccessApp(data.user, app)) {
logoutRequest();
const message =
app === "admin"
? "This account does not have super admin access."
: app === "business"
? "No business is linked to this account."
: "This account cannot use the customer dashboard.";
throw new ApiError(message, 403);
}
if (app === "business" && data.user.dashboard === "super_admin") {
const managed = syncManagedBusinessFromUrl();
setManagedBusinessIdState(managed);
if (!managed && !pickActiveBusinessId(data.user)) {
logoutRequest();
throw new ApiError(
"Open a business from the admin dashboard and use Manage business.",
403,
);
}
}
setManagedBusinessIdState(syncManagedBusinessFromUrl());
setUser(data.user);
return data.user.dashboard;
}, []);
const register = useCallback(async (payload: RegisterPayload) => {
const data = await registerRequest(payload);
if (!data.accessToken || !data.refreshToken || !data.user) {
throw new ApiError(
data.message ||
"Registration needs phone verification before you can continue.",
403,
);
}
const app = hostAppFor(currentHost());
if (!userCanAccessApp(data.user, app)) {
logoutRequest();
throw new ApiError(
"This account cannot use the customer dashboard.",
403,
);
}
setManagedBusinessIdState(syncManagedBusinessFromUrl());
setUser(data.user);
return data.user.dashboard;
}, []);
const isSuperAdmin = user?.dashboard === "super_admin";
/**
* Shared business.* host — which Meshkee business?
* - Superadmin: managed id from “Manage business” handoff (required for other businesses)
* - Everyone else: first owned / linked business on the user
*/
const activeBusinessId = useMemo(() => {
if (!user) return null;
if (user.dashboard === "super_admin") {
return managedBusinessId || pickActiveBusinessId(user);
}
return pickActiveBusinessId(user);
}, [user, managedBusinessId]);
const isBusinessOwner = useMemo(
() => Boolean(user?.businesses.some((item) => item.isOwner)),
[user],
);
const value = useMemo(
() => ({
user,
isLoading,
activeBusinessId,
isBusinessOwner,
isSuperAdmin: Boolean(isSuperAdmin),
login,
register,
logout,
}),
[
user,
isLoading,
activeBusinessId,
isBusinessOwner,
isSuperAdmin,
login,
register,
logout,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
/**
* After login: stay on the host you signed into.
* Superadmin can open the admin dashboard from customer home when needed.
*/
export function postLoginHref(dashboard: DashboardType): string {
const app = hostAppFor(currentHost());
if (app === "admin") {
return dashboard === "super_admin" ? "/" : `${getCustomerDashboardOrigin()}/`;
}
if (app === "business") return "/profile";
if (app === "customer") return "/";
if (dashboard === "super_admin") return `${getAdminDashboardOrigin()}/`;
return `${getCustomerDashboardOrigin()}/`;
}