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 };