mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -2960,6 +2960,7 @@ tags = "squish,small,tiny"
|
||||
|
||||
[config]
|
||||
plan = "Plan"
|
||||
team = "Team"
|
||||
|
||||
[config.account.overview]
|
||||
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 Plan from "@app/components/shared/config/configSections/Plan";
|
||||
import McpSection from "@app/components/shared/config/configSections/McpSection";
|
||||
import TeamSection from "@app/components/shared/config/configSections/TeamSection";
|
||||
|
||||
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];
|
||||
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user