@frank-auth/react
Version:
Flexible and customizable React UI components for Frank Authentication
835 lines (736 loc) • 25.3 kB
text/typescript
/**
* @frank-auth/react - useOrganization Hook
*
* Organization management hook that provides access to organization operations,
* member management, invitations, and organization-specific settings.
*/
import {useCallback, useEffect, useMemo, useState} from "react";
import type {
AcceptInvitationRequest, CreateMembershipRequest,
CreateOrganizationRequest,
DeclineInvitationRequest,
Organization,
OrganizationSettings,
UpdateOrganizationRequest,
} from "@frank-auth/client";
import {useAuth} from "./use-auth";
import {useAuth as useAuthProvider} from "../provider/auth-provider";
import {useConfig} from "../provider/config-provider";
import type {
AuthError,
CreateOrganizationParams,
InviteMemberParams,
OrganizationInvitation,
OrganizationMembership,
UpdateOrganizationParams,
} from "../provider/types";
// ============================================================================
// Organization Hook Interface
// ============================================================================
export interface UseOrganizationReturn {
// Organization state
organization: Organization | null;
organizations: Organization[];
activeOrganization: Organization | null;
memberships: OrganizationMembership[];
invitations: OrganizationInvitation[];
isLoaded: boolean;
isLoading: boolean;
error: AuthError | null;
// Organization management
createOrganization: (
params: CreateOrganizationParams,
) => Promise<Organization>;
updateOrganization: (
organizationId: string,
params: UpdateOrganizationParams,
) => Promise<Organization>;
deleteOrganization: (organizationId: string) => Promise<void>;
switchOrganization: (organizationId: string) => Promise<void>;
// Member management
inviteMember: (params: InviteMemberParams) => Promise<void>;
removeMember: (memberId: string) => Promise<void>;
updateMemberRole: (memberId: string, role: string) => Promise<void>;
getMembers: () => Promise<OrganizationMember[]>;
// Invitation management
acceptInvitation: (invitationId: string) => Promise<void>;
declineInvitation: (invitationId: string) => Promise<void>;
cancelInvitation: (invitationId: string) => Promise<void>;
resendInvitation: (invitationId: string) => Promise<void>;
// Settings management
updateSettings: (
settings: Partial<OrganizationSettings>,
) => Promise<OrganizationSettings>;
// Convenience properties
organizationId: string | null;
organizationName: string | null;
organizationSlug: string | null;
isOwner: boolean;
isAdmin: boolean;
isMember: boolean;
memberCount: number;
pendingInvitations: number;
// Multi-tenant helpers
hasOrganizations: boolean;
canCreateOrganization: boolean;
canSwitchOrganization: boolean;
}
export interface OrganizationMember {
id: string;
userId: string;
organizationId: string;
role: string;
status: "active" | "invited" | "suspended";
joinedAt: Date;
invitedBy?: string;
user: {
id: string;
firstName?: string;
lastName?: string;
email: string;
profileImageUrl?: string;
};
}
// ============================================================================
// Main useOrganization Hook
// ============================================================================
/**
* Organization management hook providing access to all organization functionality
*
* @example Basic organization management
* ```tsx
* import { useOrganization } from '@frank-auth/react';
*
* function OrganizationManager() {
* const {
* organization,
* organizations,
* switchOrganization,
* createOrganization,
* isOwner,
* memberCount
* } = useOrganization();
*
* return (
* <div>
* <h2>{organization?.name}</h2>
* <p>Members: {memberCount}</p>
*
* {organizations.length > 1 && (
* <select onChange={(e) => switchOrganization(e.target.value)}>
* {organizations.map((org) => (
* <option key={org.id} value={org.id}>
* {org.name}
* </option>
* ))}
* </select>
* )}
*
* {isOwner && (
* <button onClick={() => createOrganization({
* name: 'New Organization',
* slug: 'new-org'
* })}>
* Create Organization
* </button>
* )}
* </div>
* );
* }
* ```
*
* @example Member management
* ```tsx
* function MemberManager() {
* const {
* getMembers,
* inviteMember,
* removeMember,
* isAdmin
* } = useOrganization();
* const [members, setMembers] = useState([]);
*
* useEffect(() => {
* getMembers().then(setMembers);
* }, [getMembers]);
*
* if (!isAdmin) return <div>Access denied</div>;
*
* return (
* <div>
* <h3>Members</h3>
* {members.map((member) => (
* <div key={member.id}>
* <span>{member.user.email} ({member.role})</span>
* <button onClick={() => removeMember(member.id)}>
* Remove
* </button>
* </div>
* ))}
* <button onClick={() => inviteMember({
* emailAddress: 'new@example.com',
* role: 'member'
* })}>
* Invite Member
* </button>
* </div>
* );
* }
* ```
*/
export function useOrganization(): UseOrganizationReturn {
const {
organization,
organizationMemberships,
activeOrganization,
switchOrganization: authSwitchOrganization,
session,
reload,
} = useAuth();
const {sdk} = useAuthProvider();
const {apiUrl, publishableKey, userType} = useConfig();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [invitations, setInvitations] = useState<OrganizationInvitation[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AuthError | null>(null);
// Error handler
const handleError = useCallback((err: any) => {
const authError: AuthError = {
code: err.code || "UNKNOWN_ERROR",
message: err.message || "An unknown error occurred",
details: err.details,
field: err.field,
};
setError(authError);
throw authError;
}, []);
// Load organizations and invitations
const loadOrganizations = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
// Load organizations user belongs to
const orgsData = await sdk.organization.listOrganizations({
fields: [],
});
setOrganizations((orgsData.data ?? []) as any);
// Load pending invitations
// const invitationsData = await sdk.organization.listInvitations();
// setInvitations(invitationsData?.data ?? []);
} catch (err) {
console.error("Failed to load organizations:", err);
setError({
code: "ORGANIZATIONS_LOAD_FAILED",
message: "Failed to load organizations",
});
} finally {
setIsLoading(false);
}
}, [sdk.organization]);
useEffect(() => {
loadOrganizations();
}, [loadOrganizations]);
// Organization management methods
const createOrganization = useCallback(
async (params: CreateOrganizationParams): Promise<Organization> => {
if (!sdk.organization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
const createRequest: CreateOrganizationRequest = {
name: params.name,
slug: params.slug,
// description: params.description,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
settings: params.settings,
plan: params.planId ?? 'free',
// createTrialPeriod: true,
// enableAuthService: true,
// endUserLimit: 10000000,
// externalUserLimit: 0,
// orgType: "platform",
// plan: "",
};
const newOrganization =
await sdk.organization.createOrganization(createRequest);
// Refresh organizations list
await loadOrganizations();
await reload(); // Refresh auth state
return newOrganization;
} catch (err) {
return handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, loadOrganizations, reload, handleError],
);
const updateOrganization = useCallback(
async (
organizationId: string,
params: UpdateOrganizationParams,
): Promise<Organization> => {
if (!sdk.organization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
const updateRequest: UpdateOrganizationRequest = {
name: params.name,
slug: params.slug,
description: params.description,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
settings: params.settings,
};
const updatedOrganization = await sdk.organization.updateOrganization(
organizationId,
updateRequest,
);
// Refresh organizations list and auth state
await loadOrganizations();
await reload();
return updatedOrganization;
} catch (err) {
return handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, loadOrganizations, reload, handleError],
);
const deleteOrganization = useCallback(
async (organizationId: string): Promise<void> => {
if (!sdk.organization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
await sdk.organization.deleteOrganization(organizationId, {
notifyMembers: true,
confirm: true,
dataRetention: 0,
});
// Refresh organizations list and auth state
await loadOrganizations();
await reload();
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, loadOrganizations, reload, handleError],
);
const switchOrganization = useCallback(
async (organizationId: string): Promise<void> => {
await authSwitchOrganization(organizationId);
await loadOrganizations(); // Refresh data for new organization
},
[authSwitchOrganization, loadOrganizations],
);
// Member management methods
const inviteMember = useCallback(
async (params: InviteMemberParams): Promise<void> => {
if (!sdk.organization || !activeOrganization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
const inviteRequest: CreateMembershipRequest = {
emailAddress: params.emailAddress,
role: params.role,
redirectUrl: params.redirectUrl,
publicMetadata: params.publicMetadata,
privateMetadata: params.privateMetadata,
isBillingContact: false,
isPrimaryContact: false,
roleId: params.role,
sendInvitationEmail: false
};
await sdk.organization.addMember(activeOrganization.id, inviteRequest);
// Refresh invitations
await loadOrganizations();
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, activeOrganization, loadOrganizations, handleError],
);
const removeMember = useCallback(
async (memberId: string): Promise<void> => {
if (!sdk.organization || !activeOrganization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
await sdk.organization.removeMember(activeOrganization.id, memberId, {
notifyUser: true,
});
// Refresh organizations data
await loadOrganizations();
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, activeOrganization, loadOrganizations, handleError],
);
const updateMemberRole = useCallback(
async (memberId: string, role: string): Promise<void> => {
if (!sdk.organization || !activeOrganization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
await sdk.organization.updateMemberRole(
activeOrganization.id,
memberId,
{
roleId: role,
},
);
// Refresh organizations data
await loadOrganizations();
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, activeOrganization, loadOrganizations, handleError],
);
const getMembers = useCallback(async (): Promise<OrganizationMember[]> => {
if (!sdk.organization || !activeOrganization)
throw new Error("Organization service not available");
try {
const res = await sdk.organization.listMembers(activeOrganization.id);
return (res.data ?? []).map(
(item) =>
({
id: item.userId,
userId: item.userId,
organizationId: item.organizationId,
role: item.role,
status: item.status,
joinedAt: item.joinedAt,
invitedBy: item.invitedBy,
}) as OrganizationMember,
);
} catch (err) {
handleError(err);
return [];
}
}, [sdk.organization, activeOrganization, handleError]);
// Invitation management methods
const acceptInvitation = useCallback(
async (
token: string,
opts?: {
firstName?: string;
lastName?: string;
password?: string;
},
): Promise<void> => {
if (!sdk.organization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
const acceptRequest: AcceptInvitationRequest = {
...(opts ?? {}),
token,
acceptTerms: true,
};
await sdk.organization.acceptInvitation(acceptRequest);
// Refresh organizations and invitations
await loadOrganizations();
await reload(); // User now belongs to new organization
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, loadOrganizations, reload, handleError],
);
const declineInvitation = useCallback(
async (token: string): Promise<void> => {
if (!sdk.organization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
const declineRequest: DeclineInvitationRequest = {
token: token,
};
await sdk.organization.declineInvitation(declineRequest);
// Refresh invitations
await loadOrganizations();
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, loadOrganizations, handleError],
);
const cancelInvitation = useCallback(
async (invitationId: string): Promise<void> => {
if (!sdk.organization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
await sdk.organization.cancelInvitation(invitationId);
// Refresh invitations
await loadOrganizations();
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, loadOrganizations, handleError],
);
const resendInvitation = useCallback(
async (invitationId: string): Promise<void> => {
if (!sdk.organization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
await sdk.organization.resendInvitation(invitationId);
} catch (err) {
handleError(err);
} finally {
setIsLoading(false);
}
},
[sdk.organization, handleError],
);
// Settings management
const updateSettings = useCallback(
async (
settings: Partial<OrganizationSettings>,
): Promise<OrganizationSettings> => {
if (!sdk.organization || !activeOrganization)
throw new Error("Organization service not available");
try {
setIsLoading(true);
setError(null);
const updatedSettings =
await sdk.organization.updateOrganizationSettings(
activeOrganization.id,
settings,
);
// Refresh organization data
await loadOrganizations();
await reload();
return updatedSettings;
} catch (err) {
return handleError(err);
} finally {
setIsLoading(false);
}
},
[
sdk.organization,
activeOrganization,
loadOrganizations,
reload,
handleError,
],
);
// Convenience properties
const organizationId = useMemo(
() => activeOrganization?.id || null,
[activeOrganization],
);
const organizationName = useMemo(
() => activeOrganization?.name || null,
[activeOrganization],
);
const organizationSlug = useMemo(
() => activeOrganization?.slug || null,
[activeOrganization],
);
// Role-based properties
const currentMembership = useMemo(() => {
if (!activeOrganization) return null;
return organizationMemberships.find(
(m) => m.organization.id === activeOrganization.id,
);
}, [activeOrganization, organizationMemberships]);
const isOwner = useMemo(
() => currentMembership?.role === "owner",
[currentMembership],
);
const isAdmin = useMemo(
() => ["owner", "admin"].includes(currentMembership?.role || ""),
[currentMembership],
);
const isMember = useMemo(() => !!currentMembership, [currentMembership]);
// Organization statistics
const memberCount = useMemo(
() => activeOrganization?.memberCount || 0,
[activeOrganization],
);
const pendingInvitations = useMemo(
() => invitations.filter((inv) => inv.status === "pending").length,
[invitations],
);
// Multi-tenant helpers
const hasOrganizations = useMemo(
() => organizations.length > 0,
[organizations],
);
const canCreateOrganization = useMemo(() => {
// Internal users can always create organizations
if (userType === "internal") return true;
// External users can create organizations if they're owners of at least one
if (userType === "external") {
return organizationMemberships.some((m) => m.role === "owner");
}
// End users cannot create organizations
return false;
}, [userType, organizationMemberships]);
const canSwitchOrganization = useMemo(
() => organizations.length > 1,
[organizations],
);
return {
// Organization state
organization,
organizations,
activeOrganization,
memberships: organizationMemberships,
invitations,
isLoaded: !!sdk.organization,
isLoading,
error,
// Organization management
createOrganization,
updateOrganization,
deleteOrganization,
switchOrganization,
// Member management
inviteMember,
removeMember,
updateMemberRole,
getMembers,
// Invitation management
acceptInvitation,
declineInvitation,
cancelInvitation,
resendInvitation,
// Settings management
updateSettings,
// Convenience properties
organizationId,
organizationName,
organizationSlug,
isOwner,
isAdmin,
isMember,
memberCount,
pendingInvitations,
// Multi-tenant helpers
hasOrganizations,
canCreateOrganization,
canSwitchOrganization,
};
}
// ============================================================================
// Specialized Organization Hooks
// ============================================================================
/**
* Hook for organization membership and role information
*/
export function useOrganizationMembership() {
const {
activeOrganization,
memberships,
isOwner,
isAdmin,
isMember,
memberCount,
} = useOrganization();
const currentMembership = useMemo(() => {
if (!activeOrganization) return null;
return memberships.find((m) => m.organization.id === activeOrganization.id);
}, [activeOrganization, memberships]);
return {
organization: activeOrganization,
membership: currentMembership,
role: currentMembership?.role || null,
isOwner,
isAdmin,
isMember,
memberCount,
joinedAt: currentMembership?.joinedAt || null,
status: currentMembership?.status || null,
};
}
/**
* Hook for organization invitations management
*/
export function useOrganizationInvitations() {
const {
invitations,
acceptInvitation,
declineInvitation,
cancelInvitation,
resendInvitation,
inviteMember,
isAdmin,
isLoading,
error,
} = useOrganization();
const pendingInvitations = useMemo(
() => invitations.filter((inv) => inv.status === "pending"),
[invitations],
);
const expiredInvitations = useMemo(
() => invitations.filter((inv) => inv.status === "expired"),
[invitations],
);
return {
invitations,
pendingInvitations,
expiredInvitations,
acceptInvitation,
declineInvitation,
cancelInvitation: isAdmin ? cancelInvitation : undefined,
resendInvitation: isAdmin ? resendInvitation : undefined,
inviteMember: isAdmin ? inviteMember : undefined,
canManageInvitations: isAdmin,
isLoading,
error,
};
}
/**
* Hook for organization switching
*/
export function useOrganizationSwitcher() {
const {
organizations,
activeOrganization,
switchOrganization,
canSwitchOrganization,
isLoading,
} = useOrganization();
return {
organizations,
activeOrganization,
switchOrganization,
canSwitchOrganization,
isLoading,
hasMultipleOrganizations: organizations.length > 1,
};
}