From ebc28b0a14a67fa21ed95ce2ebbbaadcc5633dbc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 10 Jun 2026 15:54:18 +0100 Subject: [PATCH] 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. --- .../public/locales/en-GB/translation.toml | 1 + .../src/saas/components/AppProviders.tsx | 18 + .../config/configSections/TeamSection.tsx | 704 ++++++++++++++++++ .../shared/config/saasConfigNavSections.tsx | 10 + .../src/saas/contexts/SaaSTeamContext.tsx | 299 ++++++++ 5 files changed, 1032 insertions(+) create mode 100644 frontend/editor/src/saas/components/AppProviders.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx create mode 100644 frontend/editor/src/saas/contexts/SaaSTeamContext.tsx diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 4310a4b7a..388933960 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2960,6 +2960,7 @@ tags = "squish,small,tiny" [config] plan = "Plan" +team = "Team" [config.account.overview] confirmDelete = "Delete My Account" diff --git a/frontend/editor/src/saas/components/AppProviders.tsx b/frontend/editor/src/saas/components/AppProviders.tsx new file mode 100644 index 000000000..ea9918b27 --- /dev/null +++ b/frontend/editor/src/saas/components/AppProviders.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx new file mode 100644 index 000000000..22e20ff77 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( + + {t("team.loading", "Loading team information...")} + + ); + } + + return ( + + {/* Header */} +
+ +
+ {isEditingName ? ( + + 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(); + }} + /> + + + + + + + + ) : ( + + + {currentTeam.name} + + {isTeamLeader && !isPersonalTeam && ( + + + + )} + {isTeamLeader && ( + {t("team.leader", "LEADER")} + )} + {isPersonalTeam && ( + + {t("team.personal", "Personal")} + + )} + + )} + {!isEditingName && !isPersonalTeam && ( + + {t("team.memberCount", "{{count}} team members", { + count: currentTeam.seatsUsed, + })} + + )} +
+ {!isPersonalTeam && !isTeamLeader && !isEditingName && ( + + )} +
+
+ + {/* Upgrade Banner for Free Users */} + {isPersonalTeam && !isPro && ( + } + > + +
+ + {t( + "team.upgrade.title", + "Upgrade to Pro to unlock team features", + )} + + + {t( + "team.upgrade.description", + "Invite members, share credits, and more.", + )}{" "} + setFeaturesModalOpened(true)} + style={{ cursor: "pointer" }} + > + {t("common.learnMore", "Learn more")} + + +
+ +
+
+ )} + + {/* Team Features Modal */} + setFeaturesModalOpened(false)} + size="md" + centered + padding="xl" + withCloseButton={false} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > +
+ setFeaturesModalOpened(false)} + size="lg" + style={{ + position: "absolute", + top: -8, + right: -8, + zIndex: 1, + }} + /> + + {/* Header */} + + + {t("team.features.badge", "PRO FEATURE")} + + + {t("team.features.title", "Team Collaboration")} + + + {t( + "team.features.subtitle", + "Upgrade to Pro and unlock powerful team features", + )} + + + + {/* Features List */} + + + + } + > + + + {t("team.features.invite.title", "Invite team members")} + + + {t( + "team.features.invite.description", + "Add unlimited users with additional seat purchases", + )} + + + + + {t( + "team.features.credits.title", + "Share credits across your team", + )} + + + {t( + "team.features.credits.description", + "Pool resources for collaborative work", + )} + + + + + {t( + "team.features.dashboard.title", + "Team management dashboard", + )} + + + {t( + "team.features.dashboard.description", + "Control permissions, monitor usage, and manage members", + )} + + + + + {t("team.features.billing.title", "Centralized billing")} + + + {t( + "team.features.billing.description", + "One invoice for all team seats and usage", + )} + + + + + {/* CTA Button */} + + +
+
+ + {/* Error/Success Messages */} + {error && ( + setError(null)} withCloseButton> + {error} + + )} + + {success && ( + setSuccess(null)} withCloseButton> + {success} + + )} + + {/* Invite Members (Pro Users) */} + {isTeamLeader && isPro && ( +
+ + {t("team.invite.title", "Invite Team Member")} + +
+ + setInviteEmail(e.target.value)} + style={{ flex: 1 }} + required + error={ + inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) + ? t("team.invite.invalidEmail", "Invalid email format") + : undefined + } + /> + + +
+
+ )} + + {/* Team Members Table */} +
+ + {t("team.members.title", "Team Members")} + + + + + + {t("team.members.nameColumn", "Name")} + + + {t("team.members.emailColumn", "Email")} + + + {t("team.members.roleColumn", "Role")} + + {isTeamLeader && !isPersonalTeam && ( + + )} + + + + {teamMembers.length === 0 && teamInvitations.length === 0 ? ( + + + + {t("team.members.empty", "No team members yet.")} + + + + ) : ( + <> + {/* Active Members */} + {teamMembers.map((member) => ( + + + + {member.username} + + + + + {member.email} + + + + + {member.role} + + + {isTeamLeader && !isPersonalTeam && ( + + {member.role !== "LEADER" && ( + + + + + + + + + } + onClick={() => + handleRemove(member.id, member.email) + } + > + {t("team.members.remove", "Remove from Team")} + + + + )} + + )} + + ))} + + {/* Pending Invitations */} + {teamInvitations + .filter((inv) => inv.status === "PENDING") + .map((invitation) => ( + + + + {invitation.inviteeEmail.split("@")[0]} + + + + + {invitation.inviteeEmail} + + + + + {t("team.members.pending", "PENDING")} + + + {isTeamLeader && !isPersonalTeam && ( + + + handleCancelInvitation( + invitation.invitationId, + invitation.inviteeEmail, + ) + } + aria-label={t( + "team.invite.cancelLabel", + "Cancel invitation", + )} + > + + + + )} + + ))} + + )} + +
+
+
+ ); +}; + +export default TeamSection; diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index 27979ff5a..9ec28f42e 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -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: , + }); + } + let sections = [accountSection, ...baseSections]; // Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS diff --git a/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx b/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx new file mode 100644 index 000000000..18df1ed0d --- /dev/null +++ b/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx @@ -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; + acceptInvitation: (token: string) => Promise; + rejectInvitation: (token: string) => Promise; + cancelInvitation: (invitationId: number) => Promise; + removeMember: (memberId: number) => Promise; + leaveTeam: () => Promise; + refreshTeams: () => Promise; +} + +const SaaSTeamContext = createContext({ + 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(null); + const [teams, setTeams] = useState([]); + const [teamMembers, setTeamMembers] = useState([]); + const [teamInvitations, setTeamInvitations] = useState([]); + 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("/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( + `/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( + `/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( + "/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 ( + + {children} + + ); +} + +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 };