/**
* React bindings for @hanzo/iam.
*
* Provides a context provider, auth hooks, and org/project switching
* that can be dropped into any React application.
*
* @example
* ```tsx
* import { IamProvider, useIam, useOrganizations } from '@hanzo/iam/react'
*
* function App() {
* return (
*
*
*
* )
* }
*
* function MyApp() {
* const { user, isAuthenticated, login, logout } = useIam()
* const { organizations, currentOrg, switchOrg } = useOrganizations()
*
* if (!isAuthenticated) return
* return
Welcome, {user?.displayName}
* }
* ```
*
* @packageDocumentation
*/
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ReactNode } from "react";
import { IAM } from "./browser.js";
import type { IAMConfig } from "./browser.js";
import type { User, Organization, Project } from "./types.js";
import type { IAMToken } from "./browser.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IamProviderProps {
/** Browser IAM SDK configuration. */
config: IAMConfig;
/** Auto-initialize on mount (check stored tokens). Default: true. */
autoInit?: boolean;
/** Called when authentication state changes. */
onAuthChange?: (authenticated: boolean) => void;
children: ReactNode;
}
export interface IamContextValue {
/** The underlying IAM instance for advanced use. */
sdk: IAM;
/** The IAM configuration. */
config: IAMConfig;
/** Authenticated user (null if not logged in). */
user: User | null;
/** Whether the user is currently authenticated. */
isAuthenticated: boolean;
/** Whether initial auth check is in progress. */
isLoading: boolean;
/** Current access token (null if not authenticated). */
accessToken: string | null;
/** Redirect to IAM login page. */
login: (params?: { additionalParams?: Record }) => Promise;
/** Open IAM login in a popup. */
loginPopup: (params?: { width?: number; height?: number }) => Promise;
/** Handle OAuth callback — call on your /auth/callback route. */
handleCallback: (callbackUrl?: string) => Promise;
/** Log out and clear all tokens. */
logout: () => void;
/** Last auth error, if any. */
error: Error | null;
}
export interface OrgState {
/** All organizations the user belongs to. */
organizations: Organization[];
/** Currently selected organization. */
currentOrg: Organization | null;
/** Currently selected org ID. */
currentOrgId: string | null;
/** Switch to a different organization. */
switchOrg: (orgId: string) => void;
/** All projects for the current organization. */
projects: Project[];
/** Currently selected project. */
currentProject: Project | null;
/** Currently selected project ID within the org. */
currentProjectId: string | null;
/** Switch to a different project (null to clear). */
switchProject: (projectId: string | null) => void;
/** Whether organizations are loading. */
isLoading: boolean;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const IamContext = createContext(null);
IamContext.displayName = "IamContext";
// Storage keys for tenant persistence
const STORAGE_ORG_KEY = "hanzo_iam_current_org";
const STORAGE_PROJECT_KEY = "hanzo_iam_current_project";
const STORAGE_EXPIRES_KEY = "hanzo_iam_expires_at";
// ---------------------------------------------------------------------------
// IamProvider
// ---------------------------------------------------------------------------
/**
* Root provider for Hanzo IAM in React applications.
*
* Wrap your app (or a subtree) with this provider to enable IAM auth.
* Manages the IAM instance, token lifecycle, and auth state.
*/
export function IamProvider(props: IamProviderProps) {
const { config, autoInit = true, onAuthChange, children } = props;
const sdk = useMemo(
() => new IAM(config),
// eslint-disable-next-line react-hooks/exhaustive-deps
[config.serverUrl, config.clientId, config.redirectUri],
);
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(autoInit);
const [accessToken, setAccessToken] = useState(
sdk.getAccessToken(),
);
const [error, setError] = useState(null);
const refreshTimerRef = useRef | null>(null);
// Schedule token refresh ~60s before expiry
const scheduleRefresh = useCallback(() => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
if (sdk.isTokenExpired()) return;
const storage = config.storage ?? sessionStorage;
const expiresAtStr = storage.getItem(STORAGE_EXPIRES_KEY);
if (!expiresAtStr) return;
const msUntilRefresh = Number(expiresAtStr) - Date.now() - 60_000;
if (msUntilRefresh <= 0) {
sdk
.refreshAccessToken()
.then((tokens) => {
setAccessToken(tokens.accessToken);
scheduleRefresh();
})
.catch(() => {
setIsAuthenticated(false);
setUser(null);
setAccessToken(null);
});
return;
}
refreshTimerRef.current = setTimeout(async () => {
try {
const tokens = await sdk.refreshAccessToken();
setAccessToken(tokens.accessToken);
scheduleRefresh();
} catch {
setIsAuthenticated(false);
setUser(null);
setAccessToken(null);
}
}, msUntilRefresh);
}, [sdk, config.storage]);
// Auto-init: check stored tokens on mount
useEffect(() => {
if (!autoInit) {
setIsLoading(false);
return;
}
let cancelled = false;
const init = async () => {
try {
const token = await sdk.getValidAccessToken();
if (cancelled) return;
if (token) {
setAccessToken(token);
setIsAuthenticated(true);
try {
const info = await sdk.getUserInfo();
if (!cancelled) setUser(info as unknown as User);
} catch {
// Token valid but userinfo failed — still authenticated
}
scheduleRefresh();
onAuthChange?.(true);
} else {
onAuthChange?.(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error(String(err)));
onAuthChange?.(false);
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
init();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sdk, autoInit]);
// Cleanup refresh timer on unmount
useEffect(() => {
return () => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
};
}, []);
// Complete authentication after login/callback
const completeAuth = useCallback(
async (tokens: IAMToken) => {
setAccessToken(tokens.accessToken);
setIsAuthenticated(true);
try {
const info = await sdk.getUserInfo();
setUser(info as unknown as User);
} catch {
// ok — token valid, userinfo is optional
}
scheduleRefresh();
onAuthChange?.(true);
},
[sdk, scheduleRefresh, onAuthChange],
);
const login = useCallback(
async (params?: { additionalParams?: Record }) => {
setError(null);
await sdk.signinRedirect(params);
},
[sdk],
);
const loginPopup = useCallback(
async (params?: { width?: number; height?: number }) => {
setError(null);
try {
const tokens = await sdk.signinPopup(params);
await completeAuth(tokens);
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
setError(e);
throw e;
}
},
[sdk, completeAuth],
);
const handleCallback = useCallback(
async (callbackUrl?: string) => {
setError(null);
try {
const tokens = await sdk.handleCallback(callbackUrl);
await completeAuth(tokens);
return tokens;
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
setError(e);
throw e;
}
},
[sdk, completeAuth],
);
const logout = useCallback(() => {
sdk.clearTokens();
setUser(null);
setIsAuthenticated(false);
setAccessToken(null);
setError(null);
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
try {
localStorage.removeItem(STORAGE_ORG_KEY);
localStorage.removeItem(STORAGE_PROJECT_KEY);
} catch {
/* ok */
}
onAuthChange?.(false);
}, [sdk, onAuthChange]);
const value = useMemo(
() => ({
sdk,
config,
user,
isAuthenticated,
isLoading,
accessToken,
login,
loginPopup,
handleCallback,
logout,
error,
}),
[
sdk,
config,
user,
isAuthenticated,
isLoading,
accessToken,
login,
loginPopup,
handleCallback,
logout,
error,
],
);
return createElement(IamContext.Provider, { value }, children);
}
// ---------------------------------------------------------------------------
// useIam
// ---------------------------------------------------------------------------
/**
* Access Hanzo IAM auth state and methods.
* Must be used within an ``.
*/
export function useIam(): IamContextValue {
const ctx = useContext(IamContext);
if (!ctx) {
throw new Error("useIam() must be used within an ");
}
return ctx;
}
// ---------------------------------------------------------------------------
// useOrganizations
// ---------------------------------------------------------------------------
/**
* Manage organization switching, derived from the JWT `sub`/`owner`
* claims. Project state is preserved as an empty list — apps that
* need full org/project listings must query their own admin API.
*
* Selection is persisted to localStorage.
*/
export function useOrganizations(): OrgState {
const { isAuthenticated, accessToken } = useIam();
const [organizations, setOrganizations] = useState([]);
const [currentOrgId, setCurrentOrgId] = useState(() => {
try {
return localStorage.getItem(STORAGE_ORG_KEY);
} catch {
return null;
}
});
const [currentProjectId, setCurrentProjectId] = useState(
() => {
try {
return localStorage.getItem(STORAGE_PROJECT_KEY);
} catch {
return null;
}
},
);
// Derive primary org from the JWT (sub="org/username" or owner claim).
// No API call — IAM SDK does not query the Casdoor /api/get-organizations
// admin surface. Apps that need a multi-org list must fetch it from
// their own admin/control-plane API.
useEffect(() => {
if (!isAuthenticated || !accessToken) {
setOrganizations([]);
return;
}
try {
const payload = JSON.parse(atob(accessToken.split(".")[1]));
const sub = (payload.sub as string) || "";
const owner = (payload.owner as string) || "";
const primaryOrg = owner || (sub.includes("/") ? sub.split("/")[0] : "");
if (primaryOrg) {
const syntheticOrg: Organization = {
owner: "admin",
name: primaryOrg,
displayName: primaryOrg,
};
setOrganizations([syntheticOrg]);
if (!currentOrgId) {
setCurrentOrgId(primaryOrg);
try {
localStorage.setItem(STORAGE_ORG_KEY, primaryOrg);
} catch {
/* ok */
}
}
}
} catch {
// Invalid token format — leave organizations empty
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, accessToken]);
const projects: Project[] = [];
const currentProject: Project | null = null;
const currentOrg = useMemo(
() => organizations.find((o) => o.name === currentOrgId) ?? null,
[organizations, currentOrgId],
);
const switchOrg = useCallback((orgId: string) => {
setCurrentOrgId(orgId);
setCurrentProjectId(null);
try {
localStorage.setItem(STORAGE_ORG_KEY, orgId);
localStorage.removeItem(STORAGE_PROJECT_KEY);
} catch {
/* ok */
}
}, []);
const switchProject = useCallback((projectId: string | null) => {
setCurrentProjectId(projectId);
try {
if (projectId) {
localStorage.setItem(STORAGE_PROJECT_KEY, projectId);
} else {
localStorage.removeItem(STORAGE_PROJECT_KEY);
}
} catch {
/* ok */
}
}, []);
return {
organizations,
currentOrg,
currentOrgId,
switchOrg,
projects,
currentProject,
currentProjectId,
switchProject,
isLoading: false,
};
}
// ---------------------------------------------------------------------------
// useIamToken
// ---------------------------------------------------------------------------
/**
* Hook that provides a valid access token with auto-refresh capability.
* Returns null while loading or if not authenticated.
*/
export function useIamToken(): {
token: string | null;
isValid: boolean;
refresh: () => Promise;
} {
const { sdk, accessToken, isAuthenticated } = useIam();
const refresh = useCallback(async () => {
try {
return await sdk.getValidAccessToken();
} catch {
return null;
}
}, [sdk]);
return {
token: accessToken,
isValid: isAuthenticated && !!accessToken && !sdk.isTokenExpired(),
refresh,
};
}
// Re-export context for advanced use
export { IamContext };
// ---------------------------------------------------------------------------
// OrgProjectSwitcher component
// ---------------------------------------------------------------------------
export interface OrgProjectSwitcherProps {
organizations: Array<{ name: string; displayName?: string; owner?: string }>;
currentOrgId: string | null;
switchOrg: (orgId: string) => void;
projects?: Array<{ name: string; displayName?: string; organization?: string; isDefault?: boolean }>;
currentProjectId?: string | null;
switchProject?: (projectId: string | null) => void;
onTenantChange?: (orgId: string | null, projectId: string | null) => void;
environment?: string | null;
className?: string;
alwaysShow?: boolean;
}
/**
* Organization and project switcher component.
*
* @example
* ```tsx
* import { useOrganizations, OrgProjectSwitcher } from '@hanzo/iam/react'
*
* function Nav() {
* const orgState = useOrganizations()
* return
* }
* ```
*/
export function OrgProjectSwitcher({
organizations,
currentOrgId,
switchOrg,
projects = [],
currentProjectId = null,
switchProject,
onTenantChange,
environment,
className = "",
alwaysShow = false,
}: OrgProjectSwitcherProps) {
useEffect(() => {
onTenantChange?.(currentOrgId, currentProjectId ?? null);
}, [currentOrgId, currentProjectId, onTenantChange]);
const handleOrgChange = useCallback(
(e: { target: { value: string } }) => switchOrg(e.target.value),
[switchOrg],
);
const handleProjectChange = useCallback(
(e: { target: { value: string } }) => switchProject?.(e.target.value || null),
[switchProject],
);
if (!alwaysShow && organizations.length <= 1 && projects.length <= 1) {
if (organizations.length === 1) {
const org = organizations[0];
return createElement(
"div",
{ className: `flex items-center gap-2 text-sm ${className}` },
createElement("span", { className: "font-medium" }, org.displayName || org.name),
projects.length === 1
? [
createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
createElement("span", { key: "proj" }, projects[0].displayName || projects[0].name),
]
: null,
environment
? createElement(
"span",
{ className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
environment,
)
: null,
);
}
return null;
}
return createElement(
"div",
{ className: `flex items-center gap-2 ${className}` },
createElement(
"select",
{
value: currentOrgId ?? "",
onChange: handleOrgChange,
className:
"h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
"aria-label": "Switch organization",
},
...organizations.map((org) =>
createElement("option", { key: org.name, value: org.name }, org.displayName || org.name),
),
),
projects.length > 0 && switchProject
? [
createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
createElement(
"select",
{
key: "proj-select",
value: currentProjectId ?? "",
onChange: handleProjectChange,
className:
"h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
"aria-label": "Switch project",
},
...projects.map((proj) =>
createElement("option", { key: proj.name, value: proj.name }, proj.displayName || proj.name),
),
),
]
: null,
environment
? createElement(
"span",
{ className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
environment,
)
: null,
);
}