Files

176 lines
4.5 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
clearSharedAuthCookies,
hydrateLocalTokensFromSharedCookies,
SHARED_ACCESS_TOKEN_KEY,
SHARED_REFRESH_TOKEN_KEY,
writeSharedAuthCookies,
} from "./shared-auth-cookie";
export type AuthBusinessMembership = {
id: string;
name: string;
slug: string;
isOwner: boolean;
teamRole: string | null;
};
export type AuthUser = {
id: string;
firstName?: string;
lastName?: string;
cellNumber?: string;
email?: string;
roles?: string[];
isSuperAdmin?: boolean;
businesses?: AuthBusinessMembership[];
};
type SessionContextValue = {
user: AuthUser | null;
isAuthenticated: boolean;
ready: boolean;
businessId: string;
isWebsiteAdmin: boolean;
logout: () => void;
getAccessToken: () => string | null;
};
function normalizeUser(raw: Partial<AuthUser> & { id?: string | number }): AuthUser | null {
if (raw.id == null) return null;
return {
id: String(raw.id),
firstName: raw.firstName ?? undefined,
lastName: raw.lastName ?? undefined,
cellNumber: raw.cellNumber ?? undefined,
email: raw.email ?? undefined,
roles: Array.isArray(raw.roles) ? raw.roles : undefined,
isSuperAdmin: Boolean(raw.isSuperAdmin),
businesses: Array.isArray(raw.businesses)
? raw.businesses.map((business) => ({
id: String(business.id),
name: business.name,
slug: business.slug,
isOwner: Boolean(business.isOwner),
teamRole: business.teamRole ?? null,
}))
: undefined,
};
}
export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string): boolean {
if (!user || !businessId) return false;
if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
return Boolean(
user.businesses?.some(
(business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
),
);
}
const SessionContext = createContext<SessionContextValue | null>(null);
function readToken(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function writeToken(key: string, value: string | null) {
try {
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch {
// ignore
}
}
export function SessionProvider({
children,
businessId,
}: {
children: ReactNode;
businessId: string;
}) {
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
const getAccessToken = useCallback(() => {
hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY);
return readToken(SHARED_ACCESS_TOKEN_KEY);
}, []);
const clearTokens = useCallback(() => {
writeToken(SHARED_ACCESS_TOKEN_KEY, null);
writeToken(SHARED_REFRESH_TOKEN_KEY, null);
writeToken("accessToken", null);
writeToken("meshkee_access_token", null);
clearSharedAuthCookies();
setUser(null);
}, []);
useEffect(() => {
const token = getAccessToken();
if (!token) {
setReady(true);
return;
}
const refresh = readToken(SHARED_REFRESH_TOKEN_KEY);
writeSharedAuthCookies(token, refresh ?? undefined);
fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } })
.then(async (res) => {
if (res.status === 401) {
clearTokens();
return;
}
if (!res.ok) return;
const data = (await res.json()) as { user?: AuthUser } & AuthUser;
const next = normalizeUser(data.user ?? data);
if (next) setUser(next);
})
.catch(() => undefined)
.finally(() => setReady(true));
}, [clearTokens, getAccessToken]);
const logout = useCallback(() => {
clearTokens();
}, [clearTokens]);
const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
const value = useMemo(
() => ({
user,
isAuthenticated: Boolean(user),
ready,
businessId,
isWebsiteAdmin,
logout,
getAccessToken,
}),
[user, ready, businessId, isWebsiteAdmin, logout, getAccessToken],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSession() {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSession must be used within SessionProvider");
return ctx;
}