mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Fix existing teams logic (#7070)
# Description of Changes Paired with https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/320 Fixes the following bugs we found when testing the SaaS release: - Existing users couldn't join teams - this was because they were the last leader of their team, so it'd be left orphaned). Users now have a 'home team', which can have no members if they join another team, but they can then go back to it later. - Existing leaders didn't have unlimited seats - `saas_teams_extensions` had no row for them, so the app fell back to `max_seats=1`. The migration script fixes it. - Members without Processor access could still access the Processor - It was just checking "Are you the leader of **any** team", instead of the user's active team.
This commit is contained in:
+13
-6
@@ -43,8 +43,13 @@ public class ResourceAccessService {
|
||||
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
|
||||
}
|
||||
|
||||
/** Portal access for a roster (admin, grant, or default policy). */
|
||||
public Set<Long> usersWithPortalAccess(Collection<User> users, Set<Long> teamLeaderUserIds) {
|
||||
/**
|
||||
* Portal access for a roster (admin, grant, or default policy). {@code activeTeamLeaderUserIds}
|
||||
* must hold ids of users who lead their own active team — the set the ADMINS_AND_TEAM_LEADS
|
||||
* default admits, matching {@link #canAccessPortal}.
|
||||
*/
|
||||
public Set<Long> usersWithPortalAccess(
|
||||
Collection<User> users, Set<Long> activeTeamLeaderUserIds) {
|
||||
Set<PrincipalRef> grantedPrincipals = new HashSet<>();
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) {
|
||||
@@ -52,7 +57,7 @@ public class ResourceAccessService {
|
||||
grantedPrincipals.add(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()));
|
||||
}
|
||||
}
|
||||
Set<Long> leaderIds = teamLeaderUserIds == null ? Set.of() : teamLeaderUserIds;
|
||||
Set<Long> leaderIds = activeTeamLeaderUserIds == null ? Set.of() : activeTeamLeaderUserIds;
|
||||
Set<Long> allowed = new HashSet<>();
|
||||
for (User user : users) {
|
||||
if (user != null
|
||||
@@ -214,11 +219,13 @@ public class ResourceAccessService {
|
||||
};
|
||||
}
|
||||
|
||||
// Portal (no owner) admits any team lead; a team-owned resource admits only that team's
|
||||
// leads; a user-owned resource admits no extra leads.
|
||||
// Portal (no owner) admits the leader of the user's active team; a team-owned resource
|
||||
// admits only that team's leads; a user-owned resource admits no extra leads.
|
||||
private boolean matchesTeamLeadDefault(PrincipalRef owner, User user) {
|
||||
if (owner == null) {
|
||||
return teamLeadLookup.isAnyTeamLeader(user);
|
||||
return user.getTeam() != null
|
||||
&& user.getTeam().getId() != null
|
||||
&& teamLeadLookup.isLeaderOfTeam(user, user.getTeam().getId());
|
||||
}
|
||||
return owner.type() == PrincipalType.TEAM
|
||||
&& owner.id() != null
|
||||
|
||||
+20
-10
@@ -345,10 +345,27 @@ public class ProprietaryUIDataController {
|
||||
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
|
||||
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
|
||||
|
||||
// Resolve portal access for the whole roster.
|
||||
Set<Long> leaderUserIds = leaderUserIds();
|
||||
// Resolve portal access for the whole roster. The teamLead display flag counts a
|
||||
// LEADER membership on any team (mirrors /me), but the portal default policy only
|
||||
// admits leaders of their own active team, so the bulk check gets the narrower set.
|
||||
List<TeamMembership> leaderMemberships =
|
||||
teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER);
|
||||
Set<Long> leaderUserIds =
|
||||
leaderMemberships.stream()
|
||||
.map(row -> row.getUser().getId())
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> activeTeamLeaderUserIds =
|
||||
leaderMemberships.stream()
|
||||
.filter(
|
||||
row ->
|
||||
row.getUser().getTeam() != null
|
||||
&& row.getTeam()
|
||||
.getId()
|
||||
.equals(row.getUser().getTeam().getId()))
|
||||
.map(row -> row.getUser().getId())
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> portalAccessUserIds =
|
||||
resourceAccessService.usersWithPortalAccess(sortedUsers, leaderUserIds);
|
||||
resourceAccessService.usersWithPortalAccess(sortedUsers, activeTeamLeaderUserIds);
|
||||
List<AdminUserSummary> userSummaries =
|
||||
sortedUsers.stream()
|
||||
.map(user -> convertUserToSummary(user, leaderUserIds, portalAccessUserIds))
|
||||
@@ -536,13 +553,6 @@ public class ProprietaryUIDataController {
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
/** User ids holding a LEADER membership on any team. */
|
||||
private Set<Long> leaderUserIds() {
|
||||
return teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER).stream()
|
||||
.map(row -> row.getUser().getId())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/** Whether the user holds the internal-API authority (never shown in the roster). */
|
||||
private boolean isInternalApiUser(User user) {
|
||||
for (Authority authority : user.getAuthorities()) {
|
||||
|
||||
+3
-2
@@ -62,8 +62,9 @@ class ResourceAccessPortalBulkParityTest {
|
||||
grant(PrincipalType.USER, 3L, AccessPermission.USE),
|
||||
grant(PrincipalType.TEAM, 20L, AccessPermission.USE)));
|
||||
|
||||
// Only #2 leads a team; leaderUserIds is what the controller passes to the bulk method.
|
||||
lenient().when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true);
|
||||
// Only #2 leads their active team; leaderUserIds is what the controller passes to the
|
||||
// bulk method (the active-team-leader set).
|
||||
lenient().when(teamLeadLookup.isLeaderOfTeam(leader, 10L)).thenReturn(true);
|
||||
leaderUserIds = Set.of(2L);
|
||||
}
|
||||
|
||||
|
||||
+24
-3
@@ -239,10 +239,10 @@ class ResourceAccessServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamLeadDefaultAllowsLeaderButNotRegularUser() {
|
||||
void teamLeadDefaultAllowsActiveTeamLeaderButNotRegularUser() {
|
||||
stubGrants();
|
||||
User leader = user(5);
|
||||
when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true);
|
||||
User leader = userInTeam(5, 7L);
|
||||
when(teamLeadLookup.isLeaderOfTeam(leader, 7L)).thenReturn(true);
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE, RID, null, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, leader))
|
||||
@@ -291,6 +291,27 @@ class ResourceAccessServiceTest {
|
||||
assertThat(service.canAccessPortal(user(5))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void portalAllowedToLeaderOfActiveTeam() {
|
||||
when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, ""))
|
||||
.thenReturn(List.of());
|
||||
User leader = userInTeam(10, 21L);
|
||||
when(teamLeadLookup.isLeaderOfTeam(leader, 21L)).thenReturn(true);
|
||||
assertThat(service.canAccessPortal(leader)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void portalDeniedToMemberWhoseActiveTeamTheyDoNotLead() {
|
||||
// Durable home teams: a user still leads their dormant home team, but their ACTIVE
|
||||
// team is one they only belong to -> no portal access (this is the bug-3 guard that
|
||||
// active-team leadership preserves once home teams stop being deleted on join).
|
||||
when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, ""))
|
||||
.thenReturn(List.of());
|
||||
User member = userInTeam(11, 22L);
|
||||
// isLeaderOfTeam(member, 22L) left unstubbed -> false: member of active team.
|
||||
assertThat(service.canAccessPortal(member)).isFalse();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void stubGrants(ResourceGrant... grants) {
|
||||
|
||||
@@ -57,6 +57,11 @@ public class SaasUserExtensions implements Serializable {
|
||||
@Column(name = "api_key_first_used_at")
|
||||
private LocalDateTime apiKeyFirstUsedAt;
|
||||
|
||||
// Durable "home team" the user returns to when leaving a joined team; distinct from the
|
||||
// active users.team_id. Plain id (not a @ManyToOne) to avoid an eager Team load. Nullable.
|
||||
@Column(name = "home_team_id")
|
||||
private Long homeTeamId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
+4
-2
@@ -380,8 +380,10 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
dup));
|
||||
}
|
||||
|
||||
// Only the DB-race winner runs first-time init; the losers skip it.
|
||||
if (weCreatedThisUser) {
|
||||
// Only the DB-race winner runs first-time init; the losers skip it. Guests (anonymous
|
||||
// sessions) get NO team: the editor is free and needs none, and automation requires a
|
||||
// real account.
|
||||
if (weCreatedThisUser && !isAnonymous(jwt)) {
|
||||
try {
|
||||
savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser));
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -46,19 +46,9 @@ public class SaasTeamService {
|
||||
private final UserRoleService userRoleService;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
|
||||
private final SaasUserExtensionService saasUserExtensionService;
|
||||
private final LinkedInstanceRepository linkedInstanceRepository;
|
||||
private final stirling.software.proprietary.security.service.UserService userService;
|
||||
private final stirling.software.proprietary.access.repository.ResourceGrantRepository
|
||||
resourceGrantRepository;
|
||||
private final stirling.software.proprietary.integration.repository.IntegrationConfigRepository
|
||||
integrationConfigRepository;
|
||||
|
||||
// Team-owned integration configs + team grants FK the teams row; purge before deleting a team.
|
||||
private void purgeTeamOwnedResources(Long teamId) {
|
||||
integrationConfigRepository.deleteByOwnerTeam_Id(teamId);
|
||||
resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId(
|
||||
stirling.software.proprietary.access.model.PrincipalType.TEAM, teamId);
|
||||
}
|
||||
|
||||
public static final String DEFAULT_TEAM_NAME = "Default";
|
||||
public static final String INTERNAL_TEAM_NAME = "Internal";
|
||||
@@ -114,6 +104,9 @@ public class SaasTeamService {
|
||||
user.setTeam(savedTeam);
|
||||
userRepository.save(user);
|
||||
|
||||
// A freshly-created personal team is the user's durable home team.
|
||||
saasUserExtensionService.setHomeTeamId(user, savedTeam.getId());
|
||||
|
||||
// Clean up old Default/Internal team membership
|
||||
if (oldTeam != null
|
||||
&& (DEFAULT_TEAM_NAME.equals(oldTeam.getName())
|
||||
@@ -129,6 +122,54 @@ public class SaasTeamService {
|
||||
return savedTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's durable home team id (the team they fall back to when leaving a joined team). Uses
|
||||
* the stored pointer; if unset (e.g. an existing user before the backfill), derives it from a
|
||||
* solo team they lead and persists it. Returns null when the user has no such team (e.g. they
|
||||
* joined under the old delete-on-join flow); callers mint a fresh home in that case.
|
||||
*/
|
||||
private Long resolveHomeTeamId(User user) {
|
||||
Long homeId = saasUserExtensionService.getHomeTeamId(user);
|
||||
if (homeId != null) {
|
||||
return homeId;
|
||||
}
|
||||
for (TeamMembership m : membershipRepository.findByUserId(user.getId())) {
|
||||
if (m.isLeader() && membershipRepository.countByTeamId(m.getTeam().getId()) == 1) {
|
||||
saasUserExtensionService.setHomeTeamId(user, m.getTeam().getId());
|
||||
return m.getTeam().getId();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the user back to their durable home team. Reuses the existing home membership when
|
||||
* present (the durable model keeps it across joins); mints a fresh personal home team only when
|
||||
* the user has none.
|
||||
*/
|
||||
private void returnUserToHome(User user) {
|
||||
Long homeId = resolveHomeTeamId(user);
|
||||
Team home = homeId == null ? null : teamRepository.findById(homeId).orElse(null);
|
||||
if (home == null) {
|
||||
createPersonalTeam(user);
|
||||
return;
|
||||
}
|
||||
if (membershipRepository.findByTeamIdAndUserId(home.getId(), user.getId()).isEmpty()) {
|
||||
TeamMembership membership = new TeamMembership();
|
||||
membership.setTeam(home);
|
||||
membership.setUser(user);
|
||||
membership.setRole(TeamRole.LEADER);
|
||||
membership.setInvitedAt(LocalDateTime.now());
|
||||
membership.setAcceptedAt(LocalDateTime.now());
|
||||
membershipRepository.save(membership);
|
||||
// Ignore the result: a full home returns 0, the correct end state (1 member = 1 seat) -
|
||||
// throwing here would wrongly block the return.
|
||||
saasTeamExtensionsRepository.incrementSeatsUsed(home.getId());
|
||||
}
|
||||
userRepository.updateUserTeamId(user.getId(), home.getId());
|
||||
user.setTeam(home);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite user to team (sends email via Supabase Edge Function)
|
||||
*
|
||||
@@ -314,29 +355,29 @@ public class SaasTeamService {
|
||||
throw new IllegalStateException("Team has no available seats");
|
||||
}
|
||||
|
||||
// Validate: accepting won't orphan a team the user leads or that has a paid plan.
|
||||
// Accepting moves the user off their current team; leaveTeam already blocks the
|
||||
// last leader of a team from walking away, so accept must enforce the same rule.
|
||||
assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser);
|
||||
// Establish/park the durable home team: joining never deletes it.
|
||||
Long homeTeamId = resolveHomeTeamId(acceptingUser);
|
||||
|
||||
// User can only be in one team . leave existing teams before joining new one
|
||||
List<TeamMembership> existingMemberships =
|
||||
membershipRepository.findByUserId(acceptingUser.getId());
|
||||
List<Team> teamsToDelete = new java.util.ArrayList<>();
|
||||
// Guard: block only if joining would strand a paid/linked team the user is the last
|
||||
// leader of. Home teams are parked (kept), so a join never orphans them.
|
||||
assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser, homeTeamId, team.getId());
|
||||
|
||||
for (TeamMembership existingMembership : existingMemberships) {
|
||||
// Leave any non-home team the user currently belongs to; keep the home team + membership.
|
||||
for (TeamMembership existingMembership :
|
||||
membershipRepository.findByUserId(acceptingUser.getId())) {
|
||||
Team oldTeam = existingMembership.getTeam();
|
||||
|
||||
membershipRepository.delete(existingMembership);
|
||||
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(oldTeam.getId());
|
||||
|
||||
// Mark personal team for deletion if it's now empty (user was the only member)
|
||||
if (saasTeamExtensionService.isPersonal(oldTeam)
|
||||
&& membershipRepository.countByTeamId(oldTeam.getId()) == 0) {
|
||||
teamsToDelete.add(oldTeam);
|
||||
if ((homeTeamId != null && homeTeamId.equals(oldTeam.getId()))
|
||||
|| oldTeam.getId().equals(team.getId())) {
|
||||
continue; // keep the durable home; skip the team being joined
|
||||
}
|
||||
|
||||
// Keep any team the user leads that still has other members: leaving it would orphan
|
||||
// them (members, zero leaders). Solo/empty led teams and plain memberships still leave.
|
||||
if (existingMembership.isLeader()
|
||||
&& membershipRepository.countByTeamId(oldTeam.getId()) > 1) {
|
||||
continue;
|
||||
}
|
||||
membershipRepository.delete(existingMembership);
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(oldTeam.getId());
|
||||
log.info(
|
||||
"User {} left team {} to join team {}",
|
||||
acceptingUser.getUsername(),
|
||||
@@ -347,43 +388,27 @@ public class SaasTeamService {
|
||||
// Native query: avoids Hibernate touching the read-only supabase_auth_id column.
|
||||
userRepository.updateUserTeamId(acceptingUser.getId(), team.getId());
|
||||
acceptingUser.setTeam(team);
|
||||
log.info(
|
||||
"User {} team reference updated to team {}",
|
||||
acceptingUser.getUsername(),
|
||||
team.getName());
|
||||
|
||||
// Now safe to delete empty personal teams
|
||||
for (Team teamToDelete : teamsToDelete) {
|
||||
log.info(
|
||||
"Deleting empty personal team {} after user {} joined another team",
|
||||
teamToDelete.getId(),
|
||||
acceptingUser.getUsername());
|
||||
purgeTeamOwnedResources(teamToDelete.getId());
|
||||
teamRepository.delete(teamToDelete);
|
||||
// Add the MEMBER membership on the joined team (unless already present).
|
||||
if (membershipRepository
|
||||
.findByTeamIdAndUserId(team.getId(), acceptingUser.getId())
|
||||
.isEmpty()) {
|
||||
TeamMembership membership = new TeamMembership();
|
||||
membership.setTeam(team);
|
||||
membership.setUser(acceptingUser);
|
||||
membership.setRole(TeamRole.MEMBER);
|
||||
membership.setInvitedBy(inviter);
|
||||
membership.setInvitedAt(invitation.getCreatedAt());
|
||||
membership.setAcceptedAt(LocalDateTime.now());
|
||||
membershipRepository.save(membership);
|
||||
|
||||
// incrementSeatsUsed enforces the cap atomically; rowsUpdated==0 means at capacity.
|
||||
int rowsUpdated = saasTeamExtensionsRepository.incrementSeatsUsed(team.getId());
|
||||
if (rowsUpdated == 0) {
|
||||
throw new IllegalStateException("Team has no available seats");
|
||||
}
|
||||
}
|
||||
|
||||
// Create team membership
|
||||
TeamMembership membership = new TeamMembership();
|
||||
membership.setTeam(team);
|
||||
membership.setUser(acceptingUser);
|
||||
membership.setRole(TeamRole.MEMBER);
|
||||
membership.setInvitedBy(inviter);
|
||||
membership.setInvitedAt(invitation.getCreatedAt());
|
||||
membership.setAcceptedAt(LocalDateTime.now());
|
||||
membershipRepository.save(membership);
|
||||
|
||||
log.info(
|
||||
"User {} added to team {} with role MEMBER",
|
||||
acceptingUser.getUsername(),
|
||||
team.getName());
|
||||
|
||||
// incrementSeatsUsed enforces the seat cap atomically; rowsUpdated==0 means at capacity.
|
||||
int rowsUpdated = saasTeamExtensionsRepository.incrementSeatsUsed(team.getId());
|
||||
if (rowsUpdated == 0) {
|
||||
throw new IllegalStateException("Team has no available seats");
|
||||
}
|
||||
log.info("Team {} seats_used incremented", team.getName());
|
||||
|
||||
// Don't set inviteeUser; acceptance is recorded via status + TeamMembership row.
|
||||
invitation.setStatus(InvitationStatus.ACCEPTED);
|
||||
invitationRepository.save(invitation);
|
||||
@@ -434,89 +459,61 @@ public class SaasTeamService {
|
||||
// Atomically decrement team seats_used (prevents race condition)
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
|
||||
|
||||
// Fetch team for downstream checks
|
||||
Team team = teamRepository.findById(teamId).orElseThrow();
|
||||
// Return the removed user to their durable home team (mints one only if they have none).
|
||||
returnUserToHome(userToRemove);
|
||||
|
||||
// Create new personal team for removed user
|
||||
createPersonalTeam(userToRemove);
|
||||
|
||||
// Downgrade user to FREE tier after leaving team
|
||||
// They either had a trial (which was cancelled) or had an existing subscription
|
||||
// Either way, they should be FREE after leaving
|
||||
// Downgrade to FREE after leaving the team.
|
||||
downgradeUserToFree(userToRemove);
|
||||
|
||||
// Delete non-personal team if it's now empty
|
||||
if (!saasTeamExtensionService.isPersonal(team)
|
||||
&& membershipRepository.countByTeamId(teamId) == 0) {
|
||||
log.info("Deleting empty non-personal team {} after last member removed", teamId);
|
||||
purgeTeamOwnedResources(team.getId());
|
||||
teamRepository.delete(team);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"User {} removed user {} from team {} and created new personal team",
|
||||
"User {} removed user {} from team {}; returned them to their home team",
|
||||
remover.getId(),
|
||||
memberUserId,
|
||||
teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against silently orphaning a team when a user accepts an invite to another one.
|
||||
* Guard against orphaning a still-billing team when a user joins another.
|
||||
*
|
||||
* <p>{@link #acceptInvitation} moves a user to the inviting team by first leaving their current
|
||||
* team(s). Personal teams are disposable (they get deleted on accept), but a non-personal team
|
||||
* must not be left memberless while still billing. {@link #leaveTeam} already refuses to let
|
||||
* the last leader walk away; accept took a shortcut around that check, which let a paid team's
|
||||
* leader join another team and orphan their old team together with its live subscription.
|
||||
* <p>In the durable-home model a join parks the user's home team (keeps the team, its
|
||||
* membership and its wallet) rather than deleting it, so a plain team is never orphaned. The
|
||||
* only real hazard is a team the user is the <em>last</em> leader of that still carries live
|
||||
* billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance
|
||||
* ("Mode A"). Those block the join until the plan is cancelled / leadership transferred /
|
||||
* instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
|
||||
*
|
||||
* <p>So: for each non-personal team the user leads as its <em>last</em> leader, block the
|
||||
* accept. The message points them at the right remedy — cancel the plan if the team is paid,
|
||||
* otherwise transfer leadership first.
|
||||
*
|
||||
* <p>Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code
|
||||
* linked_instance.team_id}, so they too orphan a team that is left memberless — a personal team
|
||||
* that accept deletes, or a non-personal team left by its last leader. They're checked in that
|
||||
* same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to
|
||||
* revoke them.
|
||||
* <p>The home team and the team being joined are excluded: neither is left by the join (home is
|
||||
* parked, the joined team is kept), so their live billing cannot be stranded.
|
||||
*
|
||||
* @param user the user attempting to accept an invitation
|
||||
* @throws IllegalStateException if accepting would orphan a team the user leads or its
|
||||
* instances
|
||||
* @param homeTeamId the user's durable home team, parked by the join (may be null)
|
||||
* @param joinedTeamId the team being joined
|
||||
* @throws IllegalStateException if joining would strand a paid/linked team the user last-leads
|
||||
*/
|
||||
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
|
||||
private void assertCanLeaveCurrentTeamsToJoinAnother(
|
||||
User user, Long homeTeamId, Long joinedTeamId) {
|
||||
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
|
||||
Team team = membership.getTeam();
|
||||
boolean personal = saasTeamExtensionService.isPersonal(team);
|
||||
if (!personal && !membership.isLeader()) {
|
||||
// A non-leader leaving a shared team never orphans it.
|
||||
// Home is parked and the joined team is kept, so neither can be orphaned.
|
||||
if (team.getId().equals(joinedTeamId) || team.getId().equals(homeTeamId)) {
|
||||
continue;
|
||||
}
|
||||
if (!personal
|
||||
&& membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER)
|
||||
// Only a sole leader can strand a team; a member or co-leader leaving never does.
|
||||
if (!membership.isLeader()
|
||||
|| membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER)
|
||||
> 1) {
|
||||
// Another leader remains, so the team keeps an owner.
|
||||
continue;
|
||||
}
|
||||
// Leaving here orphans the team: a personal team is deleted on accept; a non-personal
|
||||
// team is being left by its last leader. Either way its linked self-hosted instances
|
||||
// lose their billing team, so block until they're revoked.
|
||||
if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) {
|
||||
throw new IllegalStateException(
|
||||
"Revoke linked self-hosted instances on this team before joining another"
|
||||
+ " team.");
|
||||
}
|
||||
if (personal) {
|
||||
// Personal teams are disposable (deleted on accept) and never billed/shared.
|
||||
continue;
|
||||
}
|
||||
if (hasActivePaidSubscription(team)) {
|
||||
throw new IllegalStateException(
|
||||
"Your team has an active plan and you are its last leader. Cancel the plan"
|
||||
+ " or transfer leadership before joining another team.");
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"You are the last leader of your team. Transfer leadership before joining"
|
||||
+ " another team.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,26 +546,13 @@ public class SaasTeamService {
|
||||
// Atomically decrement team seats_used (prevents race condition)
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
|
||||
|
||||
// Fetch team for downstream checks
|
||||
Team team = teamRepository.findById(teamId).orElseThrow();
|
||||
// Return the user to their durable home team (mints one only if they have none).
|
||||
returnUserToHome(user);
|
||||
|
||||
// Create new personal team for user who left
|
||||
createPersonalTeam(user);
|
||||
|
||||
// Check if user should be downgraded after leaving team
|
||||
// If user has an active subscription (including trial), they keep PRO access
|
||||
// Otherwise, downgrade to FREE tier
|
||||
// Downgrade to FREE unless they still hold their own active subscription.
|
||||
downgradeUserToFree(user);
|
||||
|
||||
// Delete non-personal team if it's now empty
|
||||
if (!saasTeamExtensionService.isPersonal(team)
|
||||
&& membershipRepository.countByTeamId(teamId) == 0) {
|
||||
log.info("Deleting empty non-personal team {} after last member left", teamId);
|
||||
purgeTeamOwnedResources(team.getId());
|
||||
teamRepository.delete(team);
|
||||
}
|
||||
|
||||
log.info("User {} left team {} and created new personal team", user.getId(), teamId);
|
||||
log.info("User {} left team {} and returned to their home team", user.getId(), teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -780,8 +764,8 @@ public class SaasTeamService {
|
||||
// Atomically decrement seats_used count (prevents race condition)
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
|
||||
|
||||
// Create new personal team for removed user
|
||||
createPersonalTeam(userToRemove);
|
||||
// Return the evicted user to their durable home team.
|
||||
returnUserToHome(userToRemove);
|
||||
|
||||
// Downgrade user to FREE tier
|
||||
downgradeUserToFree(userToRemove);
|
||||
|
||||
@@ -52,6 +52,21 @@ public class SaasUserExtensionService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** The user's durable home team id (the team they fall back to), or null if unset. */
|
||||
public Long getHomeTeamId(User user) {
|
||||
return repository
|
||||
.findByUserId(user.getId())
|
||||
.map(SaasUserExtensions::getHomeTeamId)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setHomeTeamId(User user, Long homeTeamId) {
|
||||
SaasUserExtensions ext = getOrCreate(user);
|
||||
ext.setHomeTeamId(homeTeamId);
|
||||
repository.save(ext);
|
||||
}
|
||||
|
||||
/** Idempotent first-use marker. Records the first time this user's API key fired a request. */
|
||||
@Transactional
|
||||
public void trackApiKeyFirstUse(User user) {
|
||||
|
||||
+2
@@ -276,6 +276,8 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(userService, times(1)).saveUser(any(User.class));
|
||||
// Guests get NO team; a home team + grant is provisioned only on signup/upgrade.
|
||||
verify(saasTeamService, never()).ensurePersonalTeam(any());
|
||||
// Anonymous mirror row created with null email and anon flag true.
|
||||
verify(supabaseUserService).createSupabaseUser(supabaseId, null, true);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication())
|
||||
|
||||
@@ -65,17 +65,10 @@ class SaasTeamServiceTest {
|
||||
@Mock private UserRoleService userRoleService;
|
||||
@Mock private SaasTeamExtensionService saasTeamExtensionService;
|
||||
@Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository;
|
||||
@Mock private SaasUserExtensionService saasUserExtensionService;
|
||||
@Mock private LinkedInstanceRepository linkedInstanceRepository;
|
||||
@Mock private stirling.software.proprietary.security.service.UserService userService;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.access.repository.ResourceGrantRepository
|
||||
resourceGrantRepository;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.integration.repository.IntegrationConfigRepository
|
||||
integrationConfigRepository;
|
||||
|
||||
@InjectMocks private SaasTeamService service;
|
||||
|
||||
private static final UUID SUPABASE_ID = UUID.fromString("11111111-2222-3333-4444-555555555555");
|
||||
@@ -694,7 +687,6 @@ class SaasTeamServiceTest {
|
||||
when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
|
||||
when(membershipRepository.findByUserId(5L))
|
||||
.thenReturn(List.of(membership(ownTeam, u, TeamRole.LEADER)));
|
||||
when(saasTeamExtensionService.isPersonal(ownTeam)).thenReturn(false);
|
||||
when(membershipRepository.countByTeamIdAndRole(200L, TeamRole.LEADER)).thenReturn(1L);
|
||||
when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(200L))
|
||||
.thenReturn(true);
|
||||
@@ -705,9 +697,10 @@ class SaasTeamServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"blocks accept when the user is the last leader of an unpaid non-personal team")
|
||||
void lastLeaderOfUnpaidTeam_blocksAccept() {
|
||||
@DisplayName("allows accept for the last leader of an unpaid non-personal team")
|
||||
void lastLeaderOfUnpaidTeam_allowsAccept() {
|
||||
// The old over-broad "transfer leadership" block is gone: an unpaid, unlinked team is
|
||||
// never orphaned in the durable model, so the join proceeds.
|
||||
User u = user(5L, "b@x.com", "bob");
|
||||
Team newTeam = team(100L, "Acme");
|
||||
Team ownTeam = team(200L, "Bob Co");
|
||||
@@ -719,44 +712,40 @@ class SaasTeamServiceTest {
|
||||
when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
|
||||
when(membershipRepository.findByUserId(5L))
|
||||
.thenReturn(List.of(membership(ownTeam, u, TeamRole.LEADER)));
|
||||
when(saasTeamExtensionService.isPersonal(ownTeam)).thenReturn(false);
|
||||
when(membershipRepository.countByTeamIdAndRole(200L, TeamRole.LEADER)).thenReturn(1L);
|
||||
when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(200L))
|
||||
.thenReturn(false);
|
||||
when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.acceptInvitation("tok-123", u))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Transfer leadership");
|
||||
service.acceptInvitation("tok-123", u);
|
||||
|
||||
assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED);
|
||||
verify(userRepository).updateUserTeamId(5L, 100L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"happy path: leaves personal team, deletes it, joins new team, increments seats")
|
||||
void success_migratesFromPersonalTeam() {
|
||||
@DisplayName("parks the home team (keeps it) and joins the new team, incrementing seats")
|
||||
void success_parksHomeTeamAndJoins() {
|
||||
User u = user(5L, "b@x.com", "bob");
|
||||
Team newTeam = team(100L, "Acme");
|
||||
Team personal = team(200L, "My Team");
|
||||
Team home = team(200L, "My Team");
|
||||
User inviter = user(1L, "a@x.com", "alice");
|
||||
TeamInvitation inv = pendingInvitation(newTeam, inviter, "b@x.com");
|
||||
TeamMembership personalMembership = membership(personal, u, TeamRole.LEADER);
|
||||
TeamMembership homeMembership = membership(home, u, TeamRole.LEADER);
|
||||
|
||||
when(userRepository.findById(5L)).thenReturn(Optional.of(u));
|
||||
when(invitationRepository.findByInvitationToken("tok-123"))
|
||||
.thenReturn(Optional.of(inv));
|
||||
when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
|
||||
// assertCanLeave... iterates memberships; personal team is skipped.
|
||||
when(membershipRepository.findByUserId(5L))
|
||||
.thenReturn(List.of(personalMembership))
|
||||
.thenReturn(List.of(personalMembership));
|
||||
when(saasTeamExtensionService.isPersonal(personal)).thenReturn(true);
|
||||
when(membershipRepository.countByTeamId(200L)).thenReturn(0L);
|
||||
when(saasUserExtensionService.getHomeTeamId(u)).thenReturn(200L);
|
||||
when(membershipRepository.findByUserId(5L)).thenReturn(List.of(homeMembership));
|
||||
when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1);
|
||||
|
||||
service.acceptInvitation("tok-123", u);
|
||||
|
||||
verify(membershipRepository).delete(personalMembership);
|
||||
verify(saasTeamExtensionsRepository).decrementSeatsUsed(200L);
|
||||
verify(teamRepository).delete(personal);
|
||||
// Home team + its membership are kept (parked), never deleted.
|
||||
verify(membershipRepository, never()).delete(homeMembership);
|
||||
verify(teamRepository, never()).delete(home);
|
||||
verify(userRepository).updateUserTeamId(5L, 100L);
|
||||
assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED);
|
||||
ArgumentCaptor<TeamMembership> mcap = ArgumentCaptor.forClass(TeamMembership.class);
|
||||
@@ -803,7 +792,6 @@ class SaasTeamServiceTest {
|
||||
when(membershipRepository.findByUserId(5L))
|
||||
.thenReturn(List.of(oldMembership))
|
||||
.thenReturn(List.of(oldMembership));
|
||||
when(saasTeamExtensionService.isPersonal(oldTeam)).thenReturn(false);
|
||||
when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1);
|
||||
|
||||
service.acceptInvitation("tok-123", u);
|
||||
@@ -811,6 +799,34 @@ class SaasTeamServiceTest {
|
||||
verify(teamRepository, never()).delete(oldTeam);
|
||||
verify(userRepository).updateUserTeamId(5L, 100L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps a led team with other members (parks it) instead of orphaning it")
|
||||
void soleLeaderOfSharedTeam_teamKeptNotOrphaned() {
|
||||
User u = user(5L, "b@x.com", "bob");
|
||||
Team newTeam = team(100L, "Acme");
|
||||
Team shared = team(300L, "Bob's Org"); // u solely leads it; it has other members
|
||||
TeamMembership sharedMembership = membership(shared, u, TeamRole.LEADER);
|
||||
TeamInvitation inv =
|
||||
pendingInvitation(newTeam, user(1L, "a@x.com", "alice"), "b@x.com");
|
||||
|
||||
when(userRepository.findById(5L)).thenReturn(Optional.of(u));
|
||||
when(invitationRepository.findByInvitationToken("tok-123"))
|
||||
.thenReturn(Optional.of(inv));
|
||||
when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
|
||||
when(membershipRepository.findByUserId(5L)).thenReturn(List.of(sharedMembership));
|
||||
// Shared team: sole leader, but >1 member - leaving would orphan the other member.
|
||||
when(membershipRepository.countByTeamId(300L)).thenReturn(2L);
|
||||
when(membershipRepository.countByTeamIdAndRole(300L, TeamRole.LEADER)).thenReturn(1L);
|
||||
when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1);
|
||||
|
||||
service.acceptInvitation("tok-123", u);
|
||||
|
||||
// The led team is parked: its membership is kept, not deleted.
|
||||
verify(membershipRepository, never()).delete(sharedMembership);
|
||||
verify(userRepository).updateUserTeamId(5L, 100L);
|
||||
assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================================
|
||||
@@ -987,8 +1003,8 @@ class SaasTeamServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removes the member, decrements seats, makes a personal team, downgrades")
|
||||
void success_removesMemberAndDeletesEmptyTeam() {
|
||||
@DisplayName("removes the member, decrements seats, returns them home; no team deletion")
|
||||
void success_removesMemberReturnsHome() {
|
||||
User remover = user(1L, "a@x.com", "alice");
|
||||
User target = user(2L, "b@x.com", "bob");
|
||||
Team t = team(teamId, "Acme");
|
||||
@@ -1001,19 +1017,16 @@ class SaasTeamServiceTest {
|
||||
.thenReturn(List.of(leaderM));
|
||||
when(membershipRepository.findByTeamIdAndUserId(teamId, 2L))
|
||||
.thenReturn(Optional.of(targetM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
// createPersonalTeam + downgradeUserToFree both refetch the removed user by id.
|
||||
// No home stubbed for the removed user -> returnUserToHome mints a fresh personal home.
|
||||
// target has no PRO authority, so downgrade hits the early return.
|
||||
stubCreatePersonalTeam(target, 500L);
|
||||
// team becomes empty + non-personal -> deleted
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(false);
|
||||
when(membershipRepository.countByTeamId(teamId)).thenReturn(0L);
|
||||
|
||||
service.removeTeamMember(teamId, 2L, remover);
|
||||
|
||||
verify(membershipRepository).delete(targetM);
|
||||
verify(saasTeamExtensionsRepository).decrementSeatsUsed(teamId);
|
||||
verify(teamRepository).delete(t);
|
||||
// Teams are durable now - the emptied team is not deleted.
|
||||
verify(teamRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1031,10 +1044,7 @@ class SaasTeamServiceTest {
|
||||
.thenReturn(List.of(leaderM));
|
||||
when(membershipRepository.findByTeamIdAndUserId(teamId, 2L))
|
||||
.thenReturn(Optional.of(targetM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
stubCreatePersonalTeam(target, 500L);
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(false);
|
||||
when(membershipRepository.countByTeamId(teamId)).thenReturn(2L);
|
||||
|
||||
service.removeTeamMember(teamId, 2L, remover);
|
||||
|
||||
@@ -1078,28 +1088,27 @@ class SaasTeamServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("member leaves: deletes membership, decrements, makes personal team")
|
||||
@DisplayName("member leaves: deletes membership, decrements, returns them home")
|
||||
void memberLeaves_success() {
|
||||
User u = user(1L, "a@x.com", "alice");
|
||||
Team t = team(teamId, "Acme");
|
||||
TeamMembership memberM = membership(t, u, TeamRole.MEMBER);
|
||||
when(membershipRepository.findByTeamIdAndUserId(teamId, 1L))
|
||||
.thenReturn(Optional.of(memberM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
// No home stubbed -> returnUserToHome mints a fresh personal home team.
|
||||
stubCreatePersonalTeam(u, 500L);
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
|
||||
|
||||
service.leaveTeam(teamId, u);
|
||||
|
||||
verify(membershipRepository).delete(memberM);
|
||||
verify(saasTeamExtensionsRepository).decrementSeatsUsed(teamId);
|
||||
// Personal team is never deleted on leave.
|
||||
// Teams are durable now; none is deleted on leave.
|
||||
verify(teamRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("leader leaves when another leader remains: deletes empty non-personal team")
|
||||
void leaderLeavesWithCoLeader_deletesEmptyTeam() {
|
||||
@DisplayName("co-leader leaves and returns home; the team is durable (not deleted)")
|
||||
void leaderLeavesWithCoLeader_returnsHome() {
|
||||
User u = user(1L, "a@x.com", "alice");
|
||||
Team t = team(teamId, "Acme");
|
||||
TeamMembership leaderM = membership(t, u, TeamRole.LEADER);
|
||||
@@ -1108,14 +1117,12 @@ class SaasTeamServiceTest {
|
||||
.thenReturn(Optional.of(leaderM));
|
||||
when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER))
|
||||
.thenReturn(List.of(leaderM, coLeaderM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
stubCreatePersonalTeam(u, 500L);
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(false);
|
||||
when(membershipRepository.countByTeamId(teamId)).thenReturn(0L);
|
||||
|
||||
service.leaveTeam(teamId, u);
|
||||
|
||||
verify(teamRepository).delete(t);
|
||||
verify(membershipRepository).delete(leaderM);
|
||||
verify(teamRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1126,9 +1133,7 @@ class SaasTeamServiceTest {
|
||||
TeamMembership memberM = membership(t, u, TeamRole.MEMBER);
|
||||
when(membershipRepository.findByTeamIdAndUserId(teamId, 1L))
|
||||
.thenReturn(Optional.of(memberM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
stubTeamSave(500L);
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
|
||||
// Both createPersonalTeam and downgradeUserToFree refetch by id; return the PRO user
|
||||
// with an active sub -> keep PRO.
|
||||
User proRefetch = proUser(1L, "a@x.com", "alice");
|
||||
@@ -1150,9 +1155,7 @@ class SaasTeamServiceTest {
|
||||
TeamMembership memberM = membership(t, u, TeamRole.MEMBER);
|
||||
when(membershipRepository.findByTeamIdAndUserId(teamId, 1L))
|
||||
.thenReturn(Optional.of(memberM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
stubTeamSave(500L);
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
|
||||
User proRefetch = proUser(1L, "a@x.com", "alice");
|
||||
proRefetch.setSupabaseId(SUPABASE_ID);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch));
|
||||
@@ -1172,9 +1175,7 @@ class SaasTeamServiceTest {
|
||||
TeamMembership memberM = membership(t, u, TeamRole.MEMBER);
|
||||
when(membershipRepository.findByTeamIdAndUserId(teamId, 1L))
|
||||
.thenReturn(Optional.of(memberM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
stubTeamSave(500L);
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
|
||||
// PRO user without supabaseId skips the subscription check and downgrades.
|
||||
User proRefetch = proUser(1L, "a@x.com", "alice");
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch));
|
||||
@@ -1192,9 +1193,7 @@ class SaasTeamServiceTest {
|
||||
TeamMembership memberM = membership(t, u, TeamRole.MEMBER);
|
||||
when(membershipRepository.findByTeamIdAndUserId(teamId, 1L))
|
||||
.thenReturn(Optional.of(memberM));
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(t));
|
||||
stubTeamSave(500L);
|
||||
when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
|
||||
User proRefetch = proUser(1L, "a@x.com", "alice");
|
||||
proRefetch.setSupabaseId(SUPABASE_ID);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch));
|
||||
@@ -1472,6 +1471,36 @@ class SaasTeamServiceTest {
|
||||
assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("does not block when the only linked instance is on the parked home team")
|
||||
void passesGuardWhenLinkedInstanceIsOnHomeTeam() {
|
||||
User joiner = user(USER_ID, EMAIL, EMAIL);
|
||||
Team homeTeam = team(OLD_TEAM_ID, "home-team");
|
||||
Team newTeam = team(NEW_TEAM_ID, "new-team");
|
||||
TeamInvitation invitation = pendingInvitation(newTeam, joiner);
|
||||
TeamMembership homeMembership = membership(homeTeam, joiner, TeamRole.LEADER);
|
||||
|
||||
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner));
|
||||
when(invitationRepository.findByInvitationToken(TOKEN))
|
||||
.thenReturn(Optional.of(invitation));
|
||||
when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
|
||||
// The linked team IS the durable home, so the join parks it rather than orphaning it.
|
||||
when(saasUserExtensionService.getHomeTeamId(joiner)).thenReturn(OLD_TEAM_ID);
|
||||
when(membershipRepository.findByUserId(USER_ID)).thenReturn(List.of(homeMembership));
|
||||
// Home still carries a non-revoked linked instance - the old guard wrongly blocked
|
||||
// here.
|
||||
when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID))
|
||||
.thenReturn(1L);
|
||||
when(saasTeamExtensionsRepository.incrementSeatsUsed(NEW_TEAM_ID)).thenReturn(1);
|
||||
|
||||
service.acceptInvitation(TOKEN, joiner);
|
||||
|
||||
// Home parked (never deleted), user re-pointed to the new team, invite accepted.
|
||||
verify(membershipRepository, never()).delete(homeMembership);
|
||||
verify(userRepository).updateUserTeamId(USER_ID, NEW_TEAM_ID);
|
||||
assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
private TeamInvitation pendingInvitation(Team team, User invitee) {
|
||||
TeamInvitation inv = new TeamInvitation();
|
||||
inv.setTeam(team);
|
||||
|
||||
+38
@@ -214,4 +214,42 @@ class SaasUserExtensionServiceTest {
|
||||
assertThat(captor.getValue().getApiKeyFirstUsedAt()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("home team")
|
||||
class HomeTeam {
|
||||
|
||||
@Test
|
||||
@DisplayName("getHomeTeamId returns the stored id when a row exists")
|
||||
void existing_returnsId() {
|
||||
SaasUserExtensions ext = new SaasUserExtensions(user);
|
||||
ext.setHomeTeamId(7L);
|
||||
when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext));
|
||||
|
||||
assertThat(service.getHomeTeamId(user)).isEqualTo(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getHomeTeamId returns null when no row exists")
|
||||
void missing_returnsNull() {
|
||||
when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.getHomeTeamId(user)).isNull();
|
||||
verify(repository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setHomeTeamId writes the id on the existing row and saves")
|
||||
void set_updatesExistingRow() {
|
||||
SaasUserExtensions ext = new SaasUserExtensions(user);
|
||||
when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext));
|
||||
when(repository.save(any(SaasUserExtensions.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.setHomeTeamId(user, 9L);
|
||||
|
||||
assertThat(ext.getHomeTeamId()).isEqualTo(9L);
|
||||
verify(repository).save(ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { allowConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Controllable auth state for the mocked provider.
|
||||
// Controllable auth state for the mocked provider. `portalAccess` is the collapsed
|
||||
// context value (raw user.portalAccess ?? isAdminRole(role)); `user.portalAccess`
|
||||
// is the raw tri-state (undefined until /api/v1/auth/me resolves).
|
||||
const authState: {
|
||||
session: unknown;
|
||||
loading: boolean;
|
||||
isAnonymous: boolean;
|
||||
portalAccess: boolean;
|
||||
user: { portalAccess?: boolean } | null;
|
||||
} = {
|
||||
session: null,
|
||||
loading: false,
|
||||
isAnonymous: false,
|
||||
portalAccess: false,
|
||||
user: null,
|
||||
};
|
||||
|
||||
vi.mock("@app/auth", () => ({
|
||||
@@ -24,57 +30,76 @@ vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
|
||||
|
||||
import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
|
||||
|
||||
function renderBoundary() {
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PortalAuthBoundary — SaaS", () => {
|
||||
beforeEach(() => {
|
||||
authState.session = null;
|
||||
authState.loading = false;
|
||||
authState.isAnonymous = false;
|
||||
authState.portalAccess = false;
|
||||
authState.user = null;
|
||||
});
|
||||
|
||||
it("renders the portal when a real (non-guest) Supabase session is present", () => {
|
||||
it("renders the portal for a real session WITH portal access", () => {
|
||||
authState.session = { user: { id: "u1" }, access_token: "tok" };
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
authState.portalAccess = true;
|
||||
authState.user = { portalAccess: true };
|
||||
renderBoundary();
|
||||
expect(screen.getByTestId("portal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the portal for an admin (collapsed access true before /me resolves)", () => {
|
||||
authState.session = { user: { id: "admin" }, access_token: "tok" };
|
||||
authState.portalAccess = true; // isAdminRole fallback
|
||||
authState.user = {}; // raw portalAccess still undefined
|
||||
renderBoundary();
|
||||
expect(screen.getByTestId("portal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates a real session WITHOUT portal access (member) and bounces to the editor", () => {
|
||||
authState.session = { user: { id: "member" }, access_token: "tok" };
|
||||
authState.portalAccess = false;
|
||||
authState.user = { portalAccess: false };
|
||||
allowConsole.error(/not implemented|navigation/i);
|
||||
renderBoundary();
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("waits (no portal, no redirect) while portal access is still resolving", () => {
|
||||
authState.session = { user: { id: "u1" }, access_token: "tok" };
|
||||
authState.portalAccess = false;
|
||||
authState.user = {}; // /me not back yet -> raw portalAccess undefined
|
||||
// Deliberately do NOT allow a navigation error: if the gate wrongly bounced
|
||||
// this still-resolving user, jsdom's navigation warning would fail the test.
|
||||
renderBoundary();
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates (does not render the portal) for an anonymous guest session", () => {
|
||||
authState.session = { user: { id: "guest" }, access_token: "tok" };
|
||||
authState.isAnonymous = true;
|
||||
// The gate bounces a guest to the editor; jsdom doesn't implement
|
||||
// navigation, so absorb that incidental warning.
|
||||
allowConsole.error(/not implemented|navigation/i);
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
renderBoundary();
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates (does not render the portal) when there is no session", () => {
|
||||
authState.session = null;
|
||||
// The gate bounces to /login; jsdom doesn't implement navigation, so absorb
|
||||
// that incidental warning rather than fail the console guard.
|
||||
allowConsole.error(/not implemented|navigation/i);
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
renderBoundary();
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates while the session is still resolving", () => {
|
||||
authState.loading = true;
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
renderBoundary();
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,22 +22,40 @@ function FullScreen({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS gate: viewing your own usage is not admin-gated, so any real (signed-in,
|
||||
* non-guest) account may enter - deliberately laxer than the self-hosted
|
||||
* RequirePortalAccess admin gate. But an anonymous guest session has no account
|
||||
* to view or manage, so it is not eligible: bounce it to the editor (where a
|
||||
* guest can sign up), mirroring the self-hosted forbidden path. No session at
|
||||
* all -> the editor's Supabase login, which returns here signed in.
|
||||
* SaaS portal gate: enter only with backend-granted portal/processor access
|
||||
* (`portalAccess`, from /api/v1/auth/me), mirroring self-hosted RequirePortalAccess.
|
||||
* The old "any signed-in account may enter" behaviour let team members without
|
||||
* access into the Processor.
|
||||
*
|
||||
* portalAccess resolves *after* the session does (/me runs once `loading` is
|
||||
* already false), so treat "real session, access not yet known" (raw
|
||||
* user.portalAccess still undefined, and not admin-by-role) as still-loading
|
||||
* rather than bouncing a legitimate user mid-load. Once settled: no session ->
|
||||
* login; a guest or a real account without access -> the free editor.
|
||||
*/
|
||||
function SaasPortalGate({ children }: { children: ReactNode }) {
|
||||
const { session, loading, isAnonymous } = useAuth();
|
||||
const blocked = !loading && (!session || isAnonymous);
|
||||
const { session, loading, isAnonymous, portalAccess, user } = useAuth();
|
||||
|
||||
const accessPending =
|
||||
!!session &&
|
||||
!isAnonymous &&
|
||||
!portalAccess &&
|
||||
user?.portalAccess === undefined;
|
||||
const settling = loading || accessPending;
|
||||
|
||||
const redirectTo = settling
|
||||
? null
|
||||
: !session
|
||||
? withBasePath("/login")
|
||||
: isAnonymous || !portalAccess
|
||||
? EDITOR_URL
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!blocked) return;
|
||||
// Guest (has a session but anonymous) -> editor; no session -> login.
|
||||
window.location.href = session ? EDITOR_URL : withBasePath("/login");
|
||||
}, [blocked, session]);
|
||||
if (loading || blocked) {
|
||||
if (redirectTo) window.location.href = redirectTo;
|
||||
}, [redirectTo]);
|
||||
|
||||
if (settling || redirectTo) {
|
||||
return (
|
||||
<FullScreen>
|
||||
<Spinner size="lg" />
|
||||
|
||||
@@ -141,56 +141,77 @@ export function SupabaseAuthProvider({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Enrich with backend truth: grant-based portal access and team leadership
|
||||
// come from /api/v1/auth/me, not Supabase claims. Best-effort; the
|
||||
// isAdminRole fallback below covers failures and anonymous sessions.
|
||||
// Enrich with backend truth: grant-based portal access and team leadership come from
|
||||
// /api/v1/auth/me, not Supabase claims. Portal/Processor access is active-team-dependent on the
|
||||
// backend, and a team switch made elsewhere doesn't change our session object - so besides the
|
||||
// initial load we re-validate whenever the tab regains focus, else a user whose access dropped
|
||||
// would keep seeing the Processor until a full reload. Best-effort; the isAdminRole fallback
|
||||
// below covers failures and anonymous sessions.
|
||||
useEffect(() => {
|
||||
const token = session?.access_token;
|
||||
const sessionUser = session?.user;
|
||||
if (
|
||||
!token ||
|
||||
!sessionUser ||
|
||||
sessionUser.is_anonymous ||
|
||||
sessionUser.portalAccess !== undefined
|
||||
) {
|
||||
if (!token || !sessionUser || sessionUser.is_anonymous) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetch("/api/v1/auth/me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(
|
||||
(
|
||||
data: {
|
||||
user?: { portalAccess?: boolean; teamLead?: boolean };
|
||||
} | null,
|
||||
) => {
|
||||
if (cancelled || !data?.user) return;
|
||||
const loadAccess = () => {
|
||||
void fetch("/api/v1/auth/me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then(
|
||||
(
|
||||
data: {
|
||||
user?: { portalAccess?: boolean; teamLead?: boolean };
|
||||
} | null,
|
||||
) => {
|
||||
if (cancelled || !data?.user) return;
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
user: {
|
||||
...prev.user,
|
||||
portalAccess: data.user?.portalAccess,
|
||||
teamLead: data.user?.teamLead,
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
// Backend unreachable or /me unsupported: resolve portalAccess to the role-based
|
||||
// fallback so gates awaiting it don't hang on a spinner. This deliberately ignores
|
||||
// grant-based access (a non-admin grant-holder is denied while /me is down) - grants
|
||||
// can't be known without /me, so we fail safe; a later focus refetch recovers it.
|
||||
if (cancelled) return;
|
||||
setSession((prev) =>
|
||||
prev
|
||||
prev && prev.user.portalAccess === undefined
|
||||
? {
|
||||
...prev,
|
||||
user: {
|
||||
...prev.user,
|
||||
portalAccess: data.user?.portalAccess,
|
||||
teamLead: data.user?.teamLead,
|
||||
portalAccess: isAdminRole(prev.user.role),
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
// Backend unreachable or /me unsupported: keep the claim fallback.
|
||||
});
|
||||
});
|
||||
};
|
||||
loadAccess();
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") loadAccess();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [session?.access_token, session?.user?.id, session?.user?.portalAccess]);
|
||||
}, [session?.access_token, session?.user?.id]);
|
||||
|
||||
const user = session?.user ?? null;
|
||||
const value: AuthContextValue = {
|
||||
|
||||
Reference in New Issue
Block a user