Add team settings to SaaS (#6601)

# Description of Changes
Add team settings UI to SaaS, which is currently only available in
desktop. It'd be nice to refactor this so they're more shared, but
they're slightly different so needs to be done with some care. Leaving
for followup work.
This commit is contained in:
James Brunton
2026-06-10 15:54:18 +01:00
committed by GitHub
parent 56862cc1d3
commit ebc28b0a14
5 changed files with 1032 additions and 0 deletions
@@ -2960,6 +2960,7 @@ tags = "squish,small,tiny"
[config] [config]
plan = "Plan" plan = "Plan"
team = "Team"
[config.account.overview] [config.account.overview]
confirmDelete = "Delete My Account" confirmDelete = "Delete My Account"
@@ -0,0 +1,18 @@
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { AppProvidersProps } from "@core/components/AppProviders";
import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
export function AppProviders({
children,
appConfigRetryOptions,
appConfigProviderProps,
}: AppProvidersProps) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<SaaSTeamProvider>{children}</SaaSTeamProvider>
</ProprietaryAppProviders>
);
}
@@ -0,0 +1,704 @@
import React, { useState, useEffect } from "react";
import {
Button,
TextInput,
Group,
Text,
Stack,
Alert,
Table,
Badge,
ActionIcon,
Menu,
List,
ThemeIcon,
Modal,
CloseButton,
Anchor,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useAuth } from "@app/auth/UseSession";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import apiClient from "@app/services/apiClient";
const TeamSection: React.FC = () => {
const { t } = useTranslation();
const {
currentTeam,
teamMembers,
teamInvitations,
isTeamLeader,
isPersonalTeam,
inviteUser,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
} = useSaaSTeam();
const { isPro } = useAuth();
const [inviteEmail, setInviteEmail] = useState("");
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [featuresModalOpened, setFeaturesModalOpened] = useState(false);
// Team rename state
const [isEditingName, setIsEditingName] = useState(false);
const [newTeamName, setNewTeamName] = useState("");
const [renamingTeam, setRenamingTeam] = useState(false);
// Refresh team data on mount and every 10 seconds
useEffect(() => {
// Refresh immediately on mount
refreshTeams();
// Then refresh every 10 seconds
const interval = setInterval(() => {
refreshTeams();
}, 10000);
return () => clearInterval(interval);
}, []); // Only run on mount/unmount
const navigateToPlan = () => {
window.dispatchEvent(
new CustomEvent("appConfig:navigate", { detail: { key: "plan" } }),
);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
setInviting(true);
setError(null);
setSuccess(null);
try {
await inviteUser(inviteEmail);
setSuccess(
t("team.inviteSent", "Invitation sent to {{email}}", {
email: inviteEmail,
}),
);
setInviteEmail("");
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(
error.response?.data?.error ||
t("team.inviteError", "Failed to send invitation"),
);
} finally {
setInviting(false);
}
};
const handleRemove = async (memberId: number, memberEmail: string) => {
if (
!window.confirm(
t("team.confirmRemove", "Remove {{email}} from the team?", {
email: memberEmail,
}),
)
)
return;
try {
await removeMember(memberId);
setSuccess(t("team.memberRemoved", "Member removed successfully"));
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(
error.response?.data?.error ||
t("team.removeError", "Failed to remove member"),
);
}
};
const handleCancelInvitation = async (
invitationId: number,
email: string,
) => {
if (
!window.confirm(
t("team.confirmCancelInvite", "Cancel invitation for {{email}}?", {
email,
}),
)
)
return;
try {
await cancelInvitation(invitationId);
setSuccess(
t("team.inviteCancelled", "Invitation for {{email}} cancelled", {
email,
}),
);
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setError(
error.response?.data?.error ||
t("team.cancelInviteError", "Failed to cancel invitation"),
);
}
};
const handleStartRename = () => {
if (currentTeam) {
setNewTeamName(currentTeam.name);
setIsEditingName(true);
}
};
const handleCancelRename = () => {
setIsEditingName(false);
setNewTeamName("");
};
const handleRenameSubmit = async () => {
if (!currentTeam || !newTeamName.trim()) return;
setRenamingTeam(true);
setError(null);
try {
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/rename`, {
newName: newTeamName.trim(),
});
setSuccess(t("team.renameSuccess", "Team renamed successfully"));
setIsEditingName(false);
await refreshTeams();
} catch (err) {
const error = err as {
response?: { data?: { error?: string } };
message?: string;
};
setError(
error.response?.data?.error ||
error.message ||
t("team.renameError", "Failed to rename team"),
);
} finally {
setRenamingTeam(false);
}
};
const handleLeaveTeam = async () => {
if (!currentTeam || isPersonalTeam) return;
const confirmMessage = isTeamLeader
? t(
"team.confirmLeaveLeader",
'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.',
{ name: currentTeam.name },
)
: t("team.confirmLeave", 'Are you sure you want to leave "{{name}}"?', {
name: currentTeam.name,
});
if (!window.confirm(confirmMessage)) return;
try {
await leaveTeam();
setSuccess(t("team.leaveSuccess", "Successfully left team"));
} catch (err) {
const error = err as {
response?: { data?: { error?: string } };
message?: string;
};
setError(
error.response?.data?.error ||
error.message ||
t("team.leaveError", "Failed to leave team"),
);
}
};
if (!currentTeam) {
return (
<Alert color="gray">
<Text>{t("team.loading", "Loading team information...")}</Text>
</Alert>
);
}
return (
<Stack gap="lg">
{/* Header */}
<div>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
{isEditingName ? (
<Group gap="xs" align="center">
<TextInput
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
placeholder={t("team.namePlaceholder", "Team name")}
style={{ flex: 1, maxWidth: 300 }}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") handleCancelRename();
}}
/>
<ActionIcon
variant="filled"
color="blue"
onClick={handleRenameSubmit}
loading={renamingTeam}
disabled={!newTeamName.trim()}
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={handleCancelRename}
disabled={renamingTeam}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
) : (
<Group gap="xs" align="center">
<Text fw={600} size="lg">
{currentTeam.name}
</Text>
{isTeamLeader && !isPersonalTeam && (
<ActionIcon
variant="subtle"
size="sm"
onClick={handleStartRename}
aria-label={t("team.editName", "Edit team name")}
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
)}
{isTeamLeader && (
<Badge color="blue">{t("team.leader", "LEADER")}</Badge>
)}
{isPersonalTeam && (
<Badge color="gray" variant="light" size="xs">
{t("team.personal", "Personal")}
</Badge>
)}
</Group>
)}
{!isEditingName && !isPersonalTeam && (
<Text size="sm" c="dimmed" mt={4}>
{t("team.memberCount", "{{count}} team members", {
count: currentTeam.seatsUsed,
})}
</Text>
)}
</div>
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
<Button
color="red"
variant="outline"
size="xs"
onClick={handleLeaveTeam}
leftSection={
<LocalIcon icon="logout" width="1rem" height="1rem" />
}
>
{t("team.leaveButton", "Leave Team")}
</Button>
)}
</Group>
</div>
{/* Upgrade Banner for Free Users */}
{isPersonalTeam && !isPro && (
<Alert
color="blue"
icon={<LocalIcon icon="info" width={16} height={16} />}
>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t(
"team.upgrade.title",
"Upgrade to Pro to unlock team features",
)}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{t(
"team.upgrade.description",
"Invite members, share credits, and more.",
)}{" "}
<Anchor
size="xs"
onClick={() => setFeaturesModalOpened(true)}
style={{ cursor: "pointer" }}
>
{t("common.learnMore", "Learn more")}
</Anchor>
</Text>
</div>
<Button size="sm" variant="light" onClick={navigateToPlan}>
{t("team.upgrade.button", "Upgrade to Pro")}
</Button>
</Group>
</Alert>
)}
{/* Team Features Modal */}
<Modal
opened={featuresModalOpened}
onClose={() => setFeaturesModalOpened(false)}
size="md"
centered
padding="xl"
withCloseButton={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setFeaturesModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header */}
<Stack gap="md" align="center">
<Badge size="lg" color="violet" variant="filled">
{t("team.features.badge", "PRO FEATURE")}
</Badge>
<Text size="xl" fw={700} ta="center">
{t("team.features.title", "Team Collaboration")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"team.features.subtitle",
"Upgrade to Pro and unlock powerful team features",
)}
</Text>
</Stack>
{/* Features List */}
<List
spacing="md"
size="sm"
icon={
<ThemeIcon color="violet" size={24} radius="xl" variant="light">
<LocalIcon icon="check" width={14} height={14} />
</ThemeIcon>
}
>
<List.Item>
<Text fw={500}>
{t("team.features.invite.title", "Invite team members")}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.invite.description",
"Add unlimited users with additional seat purchases",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t(
"team.features.credits.title",
"Share credits across your team",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.credits.description",
"Pool resources for collaborative work",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t(
"team.features.dashboard.title",
"Team management dashboard",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.dashboard.description",
"Control permissions, monitor usage, and manage members",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t("team.features.billing.title", "Centralized billing")}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.billing.description",
"One invoice for all team seats and usage",
)}
</Text>
</List.Item>
</List>
{/* CTA Button */}
<Button
size="md"
fullWidth
onClick={() => {
setFeaturesModalOpened(false);
navigateToPlan();
}}
>
{t("team.features.viewPlans", "View Pro Plans")}
</Button>
</Stack>
</div>
</Modal>
{/* Error/Success Messages */}
{error && (
<Alert color="red" onClose={() => setError(null)} withCloseButton>
{error}
</Alert>
)}
{success && (
<Alert color="green" onClose={() => setSuccess(null)} withCloseButton>
{success}
</Alert>
)}
{/* Invite Members (Pro Users) */}
{isTeamLeader && isPro && (
<div>
<Text fw={600} size="md" mb="sm">
{t("team.invite.title", "Invite Team Member")}
</Text>
<form onSubmit={handleInvite}>
<Group>
<TextInput
type="email"
placeholder={t("team.invite.placeholder", "[email protected]")}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
style={{ flex: 1 }}
required
error={
inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
? t("team.invite.invalidEmail", "Invalid email format")
: undefined
}
/>
<Button
type="submit"
loading={inviting}
disabled={
!inviteEmail.trim() ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail)
}
>
{t("team.invite.sendButton", "Send Invite")}
</Button>
</Group>
</form>
</div>
)}
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">
{t("team.members.title", "Team Members")}
</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr
style={{ backgroundColor: "var(--mantine-color-gray-0)" }}
>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.nameColumn", "Name")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.emailColumn", "Email")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("team.members.roleColumn", "Role")}
</Table.Th>
{isTeamLeader && !isPersonalTeam && (
<Table.Th style={{ width: 50 }}></Table.Th>
)}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamMembers.length === 0 && teamInvitations.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={isTeamLeader && !isPersonalTeam ? 4 : 3}>
<Text ta="center" c="dimmed" py="xl">
{t("team.members.empty", "No team members yet.")}
</Text>
</Table.Td>
</Table.Tr>
) : (
<>
{/* Active Members */}
{teamMembers.map((member) => (
<Table.Tr key={`member-${member.id}`}>
<Table.Td>
<Text size="sm" fw={500}>
{member.username}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{member.email}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={member.role === "LEADER" ? "blue" : undefined}
style={
member.role !== "LEADER"
? {
backgroundColor: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
}
: undefined
}
>
{member.role}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
{member.role !== "LEADER" && (
<Menu
position="bottom-end"
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Menu.Target>
<ActionIcon variant="subtle">
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
leftSection={
<LocalIcon
icon="person-remove"
width="1rem"
height="1rem"
/>
}
onClick={() =>
handleRemove(member.id, member.email)
}
>
{t("team.members.remove", "Remove from Team")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
)}
</Table.Tr>
))}
{/* Pending Invitations */}
{teamInvitations
.filter((inv) => inv.status === "PENDING")
.map((invitation) => (
<Table.Tr key={`invitation-${invitation.invitationId}`}>
<Table.Td>
<Text size="sm" fw={500} c="dimmed" fs="italic">
{invitation.inviteeEmail.split("@")[0]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{invitation.inviteeEmail}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color="yellow" variant="light">
{t("team.members.pending", "PENDING")}
</Badge>
</Table.Td>
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
onClick={() =>
handleCancelInvitation(
invitation.invitationId,
invitation.inviteeEmail,
)
}
aria-label={t(
"team.invite.cancelLabel",
"Cancel invitation",
)}
>
<LocalIcon
icon="close"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Table.Td>
)}
</Table.Tr>
))}
</>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
);
};
export default TeamSection;
@@ -10,6 +10,7 @@ import PasswordSecurity from "@app/components/shared/config/configSections/Passw
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import Plan from "@app/components/shared/config/configSections/Plan"; import Plan from "@app/components/shared/config/configSections/Plan";
import McpSection from "@app/components/shared/config/configSections/McpSection"; import McpSection from "@app/components/shared/config/configSections/McpSection";
import TeamSection from "@app/components/shared/config/configSections/TeamSection";
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>; type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
@@ -173,6 +174,15 @@ export function createSaasConfigNavSections(
], ],
}; };
if (!isAnonymous) {
accountSection.items.push({
key: "teams",
label: t("config.team", "Team"),
icon: "groups-rounded",
component: <TeamSection />,
});
}
let sections = [accountSection, ...baseSections]; let sections = [accountSection, ...baseSections];
// Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS // Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS
@@ -0,0 +1,299 @@
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useCallback,
} from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
/**
* SaaS web implementation of SaaS Team Context
* Provides team management for authenticated (non-anonymous) users
*/
interface Team {
teamId: number;
name: string;
teamType: string;
isPersonal: boolean;
memberCount: number;
seatCount: number;
seatsUsed: number;
maxSeats: number;
isLeader: boolean;
}
interface TeamMember {
id: number;
username: string;
email: string;
role: string;
joinedAt: string;
}
interface TeamInvitation {
invitationId: number;
teamName: string;
inviterEmail: string;
inviteeEmail: string;
invitationToken: string;
status: string;
expiresAt: string;
}
interface SaaSTeamContextType {
currentTeam: Team | null;
teams: Team[];
teamMembers: TeamMember[];
teamInvitations: TeamInvitation[];
receivedInvitations: TeamInvitation[];
isTeamLeader: boolean;
isPersonalTeam: boolean;
loading: boolean;
inviteUser: (email: string) => Promise<void>;
acceptInvitation: (token: string) => Promise<void>;
rejectInvitation: (token: string) => Promise<void>;
cancelInvitation: (invitationId: number) => Promise<void>;
removeMember: (memberId: number) => Promise<void>;
leaveTeam: () => Promise<void>;
refreshTeams: () => Promise<void>;
}
const SaaSTeamContext = createContext<SaaSTeamContextType>({
currentTeam: null,
teams: [],
teamMembers: [],
teamInvitations: [],
receivedInvitations: [],
isTeamLeader: false,
isPersonalTeam: true,
loading: true,
inviteUser: async () => {},
acceptInvitation: async () => {},
rejectInvitation: async () => {},
cancelInvitation: async () => {},
removeMember: async () => {},
leaveTeam: async () => {},
refreshTeams: async () => {},
});
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
const [currentTeam, setCurrentTeam] = useState<Team | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
const [receivedInvitations, setReceivedInvitations] = useState<
TeamInvitation[]
>([]);
const [loading, setLoading] = useState(true);
const { user, refreshCredits, refreshSession } = useAuth();
const canUseTeams = !!user && !isUserAnonymous(user);
const fetchMyTeams = useCallback(async () => {
if (!canUseTeams) return null;
try {
const response = await apiClient.get<Team[]>("/api/v1/team/my", {
suppressErrorToast: true,
});
setTeams(response.data);
const activeTeam = response.data[0];
setCurrentTeam(activeTeam || null);
return activeTeam || null;
} catch (error) {
console.error("[SaaSTeamContext] Failed to fetch teams:", error);
return null;
}
}, [canUseTeams]);
const fetchTeamMembers = useCallback(async (teamId: number) => {
try {
const response = await apiClient.get<TeamMember[]>(
`/api/v1/team/${teamId}/members`,
{ suppressErrorToast: true },
);
setTeamMembers(response.data);
} catch (error) {
console.error("[SaaSTeamContext] Failed to fetch team members:", error);
}
}, []);
const fetchTeamInvitations = useCallback(
async (teamId?: number) => {
if (!canUseTeams || !teamId) return;
try {
const response = await apiClient.get<TeamInvitation[]>(
`/api/v1/team/${teamId}/invitations`,
{ suppressErrorToast: true },
);
setTeamInvitations(response.data);
} catch (error) {
console.error(
"[SaaSTeamContext] Failed to fetch team invitations:",
error,
);
}
},
[canUseTeams],
);
const fetchReceivedInvitations = useCallback(async () => {
if (!canUseTeams) return;
try {
const response = await apiClient.get<TeamInvitation[]>(
"/api/v1/team/invitations/pending",
{ suppressErrorToast: true },
);
setReceivedInvitations(response.data);
} catch (error) {
console.error(
"[SaaSTeamContext] Failed to fetch received invitations:",
error,
);
}
}, [canUseTeams]);
useEffect(() => {
if (canUseTeams) {
fetchMyTeams();
fetchReceivedInvitations();
} else {
setTeams([]);
setCurrentTeam(null);
setTeamMembers([]);
setTeamInvitations([]);
setReceivedInvitations([]);
setLoading(false);
}
}, [canUseTeams, fetchMyTeams, fetchReceivedInvitations]);
useEffect(() => {
if (currentTeam && !currentTeam.isPersonal) {
fetchTeamMembers(currentTeam.teamId);
// Only fetch invitations if user is team leader
if (currentTeam.isLeader) {
fetchTeamInvitations(currentTeam.teamId);
} else {
setTeamInvitations([]);
}
} else {
setTeamMembers([]);
setTeamInvitations([]);
}
setLoading(false);
}, [currentTeam, fetchTeamMembers, fetchTeamInvitations]);
const inviteUser = async (email: string) => {
if (!currentTeam) throw new Error("No current team");
await apiClient.post("/api/v1/team/invite", {
teamId: currentTeam.teamId,
email,
});
await fetchTeamInvitations(currentTeam.teamId);
};
const refreshTeams = useCallback(async () => {
const newCurrentTeam = await fetchMyTeams();
await fetchReceivedInvitations();
if (newCurrentTeam && !newCurrentTeam.isPersonal) {
await fetchTeamMembers(newCurrentTeam.teamId);
// Only fetch invitations if user is team leader
if (newCurrentTeam.isLeader) {
await fetchTeamInvitations(newCurrentTeam.teamId);
}
}
}, [
fetchMyTeams,
fetchReceivedInvitations,
fetchTeamMembers,
fetchTeamInvitations,
]);
const acceptInvitation = async (token: string) => {
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
await fetchReceivedInvitations();
await refreshTeams();
await refreshCredits();
await refreshSession();
};
const rejectInvitation = async (token: string) => {
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
await fetchReceivedInvitations();
};
const cancelInvitation = async (invitationId: number) => {
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
if (currentTeam) {
await fetchTeamInvitations(currentTeam.teamId);
}
};
const removeMember = async (memberId: number) => {
if (!currentTeam) throw new Error("No current team");
await apiClient.delete(
`/api/v1/team/${currentTeam.teamId}/members/${memberId}`,
);
await refreshTeams();
await fetchTeamMembers(currentTeam.teamId);
// No need to refresh session/credits: the team leader's status hasn't changed
};
const leaveTeam = async () => {
if (!currentTeam) throw new Error("No current team");
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
await refreshTeams();
await refreshCredits();
await refreshSession();
};
const isTeamLeader = currentTeam?.isLeader ?? false;
const isPersonalTeam = currentTeam?.isPersonal ?? true;
return (
<SaaSTeamContext.Provider
value={{
currentTeam,
teams,
teamMembers,
teamInvitations,
receivedInvitations,
isTeamLeader,
isPersonalTeam,
loading,
inviteUser,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
leaveTeam,
refreshTeams,
}}
>
{children}
</SaaSTeamContext.Provider>
);
}
export function useSaaSTeam() {
const context = useContext(SaaSTeamContext);
if (context === undefined) {
throw new Error("useSaaSTeam must be used within a SaaSTeamProvider");
}
return context;
}
export { SaaSTeamContext };
export type { Team, TeamMember, TeamInvitation };