From 96a292e2b9ea1bbcca488b3d0f4b4e71caaf0bcc Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:17:24 -0600 Subject: [PATCH] fix --- .../Organization/TeamSettings/MemberRow.tsx | 2 +- .../TemporaryAccessMember.utils.test.ts | 80 ++++++++++++++++--- .../TemporaryAccessMember.utils.ts | 74 +++++++++++++---- .../guest-invite-tracking.test.ts | 28 +++++++ .../TemporaryAccess/guest-invite-tracking.ts | 51 ++++++++++++ ...organization-invitation-create-mutation.ts | 7 +- .../organization-members-query.ts | 6 ++ 7 files changed, 222 insertions(+), 26 deletions(-) create mode 100644 apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts create mode 100644 apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index 6bcb1861ae7..ac4e5a0b8ea 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -110,7 +110,7 @@ export const MemberRow = ({ member, grantsByUserId }: MemberRowProps) => { )} {member.is_sso_user && SSO} - {isExternalCollaborator && Guest} + {isExternalCollaborator && Guest} {(member.metadata as any)?.origin && ( { const pendingMember = { invited_id: 1, role_ids: [301], + invited_is_external_collaborator: true, } as OrganizationMember const teamRoles = { org_scoped_roles: [ @@ -85,17 +86,43 @@ describe('TemporaryAccessMember.utils', () => { ]) }) - it('detects pending external collaborator invites with scoped projects metadata', () => { + it('does not treat multi-project read-only pending invites as external collaborators', () => { const pendingMember = { - invited_id: 2, - role_ids: [4], - invited_role_scoped_projects: ['proj-a'], + invited_id: 3, + role_ids: [301], } as OrganizationMember + const teamRoles = { + org_scoped_roles: [ + { id: 4, name: 'Read-only', base_role_id: 4, description: null, projects: [] }, + ], + project_scoped_roles: [ + { + id: 301, + name: 'Read-only_proj-a', + base_role_id: 4, + description: null, + projects: [ + { ref: 'proj-a', name: 'Alpha' }, + { ref: 'proj-b', name: 'Beta' }, + ], + }, + ], + } as never - expect(isExternalCollaboratorMember(pendingMember, roles)).toBe(true) + expect(isExternalCollaboratorMember(pendingMember, teamRoles)).toBe(false) }) - it('detects accepted external collaborators with project-scoped read-only without jit grants', () => { + it('does not treat org-scoped read-only pending invites as external collaborators', () => { + const pendingMember = { + invited_id: 4, + role_ids: [4], + invited_role_scoped_projects: ['proj-a', 'proj-b'], + } as OrganizationMember + + expect(isExternalCollaboratorMember(pendingMember, roles)).toBe(false) + }) + + it('detects accepted external collaborators with project-scoped read-only and jit grants', () => { const acceptedMember = { gotrue_id: 'user-2', role_ids: [301], @@ -114,16 +141,51 @@ describe('TemporaryAccessMember.utils', () => { }, ], } as never + const jitSummary = { + grants: [], + status: { active: 1, expired: 0, activeIp: 0, expiredIp: 0 }, + } - expect(isExternalCollaboratorMember(acceptedMember, teamRoles, { jitSummary: null })).toBe(true) + expect(isExternalCollaboratorMember(acceptedMember, teamRoles, { jitSummary })).toBe(true) }) - it('does not treat org-wide read-only members as external collaborators', () => { + it('does not treat project-scoped read-only members without jit grants as external collaborators', () => { + const readOnlyMember = { + gotrue_id: 'user-4', + role_ids: [301], + } as OrganizationMember + const teamRoles = { + org_scoped_roles: [ + { id: 4, name: 'Read-only', base_role_id: 4, description: null, projects: [] }, + ], + project_scoped_roles: [ + { + id: 301, + name: 'Read-only_proj-a', + base_role_id: 4, + description: null, + projects: [{ ref: 'proj-a', name: 'Alpha' }], + }, + ], + } as never + + expect(isExternalCollaboratorMember(readOnlyMember, teamRoles, { jitSummary: null })).toBe( + false + ) + expect(getMemberRoleNames(readOnlyMember, teamRoles)).toEqual(['Read-only']) + }) + + it('does not treat org-wide read-only members with jit grants as external collaborators', () => { + const jitSummary = { + grants: [], + status: { active: 1, expired: 0, activeIp: 0, expiredIp: 0 }, + } + expect( isExternalCollaboratorMember( { gotrue_id: 'user-3', role_ids: [4] } as OrganizationMember, roles, - { jitSummary: null } + { jitSummary } ) ).toBe(false) }) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts index ee7a988f140..abadc521b83 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts @@ -76,6 +76,39 @@ function memberHasProjectScopedReadOnlyRole( }) } +function memberHasOrgScopedRoleName( + member: OrganizationMember, + roles: OrganizationRolesData, + roleName: string +) { + const orgScopedRoles = roles.org_scoped_roles ?? [] + + return (member.role_ids ?? []).some((roleId) => { + const orgRole = orgScopedRoles.find((role) => role.id === roleId) + return orgRole?.name === roleName + }) +} + +function getPendingInviteProjectScopeCount( + member: OrganizationMember, + roles: OrganizationRolesData +) { + const projectRefs = new Set() + + if (member.invited_role_scoped_projects?.length) { + member.invited_role_scoped_projects.forEach((projectRef) => projectRefs.add(projectRef)) + return projectRefs.size + } + + const projectScopedRoles = roles.project_scoped_roles ?? [] + for (const roleId of member.role_ids ?? []) { + const projectRole = projectScopedRoles.find((role) => role.id === roleId) + projectRole?.projects.forEach((project) => projectRefs.add(project.ref)) + } + + return projectRefs.size +} + /** External collaborator in Team: pending invite or accepted guest with read-only platform access. */ export function isExternalCollaboratorMember( member: OrganizationMember, @@ -85,31 +118,42 @@ export function isExternalCollaboratorMember( if (!roles) return false const orgScopedRoles = roles.org_scoped_roles ?? [] - const isReadOnlyGuest = isTemporaryAccessGuestMember(member, roles) const hasProjectScopedReadOnly = memberHasProjectScopedReadOnlyRole(member, roles) if (member.invited_id) { - if (hasProjectScopedReadOnly) return true + if (member.invited_is_external_collaborator) return true + + // Standard Read-only / Developer / Admin invites use org-scoped role ids. + if (memberHasOrgScopedRoleName(member, roles, 'Read-only')) return false + if (memberHasOrgScopedRoleName(member, roles, 'Owner')) return false + if (memberHasOrgScopedRoleName(member, roles, 'Administrator')) return false const scopedProjects = member.invited_role_scoped_projects ?? [] - if (isReadOnlyGuest && scopedProjects.length === 1) return true - const orgHasReadOnly = orgScopedRoles.some((role) => role.name === 'Read-only') - const hasOrgDeveloperRole = (member.role_ids ?? []).some((roleId) => { - const orgRole = orgScopedRoles.find((role) => role.id === roleId) - return orgRole?.name === 'Developer' - }) - if (!orgHasReadOnly && hasOrgDeveloperRole && scopedProjects.length === 1) return true + if (memberHasOrgScopedRoleName(member, roles, 'Developer')) { + // Free/Pro fallback when org Read-only is absent from /roles. + return !orgHasReadOnly && scopedProjects.length === 1 + } - return false + // External collaborators are always single-project; multi-project pending invites are team members. + if (!hasProjectScopedReadOnly) return false + + return getPendingInviteProjectScopeCount(member, roles) === 1 } - if (hasProjectScopedReadOnly) return true - - if (!isReadOnlyGuest) return false - const jitSummary = options?.jitSummary - return Boolean(jitSummary && (jitSummary.status.active > 0 || jitSummary.status.expired > 0)) + const hasJitGrants = Boolean( + jitSummary && (jitSummary.status.active > 0 || jitSummary.status.expired > 0) + ) + if (!hasJitGrants || !hasProjectScopedReadOnly) return false + + // Internal Read-only members keep org-scoped role ids even when scoped to projects. + const hasOrgScopedReadOnlyRole = (member.role_ids ?? []).some((roleId) => { + const orgRole = orgScopedRoles.find((role) => role.id === roleId) + return orgRole?.name === 'Read-only' + }) + + return !hasOrgScopedReadOnlyRole } export function getMemberJitGrantSummary( diff --git a/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts new file mode 100644 index 00000000000..380b09fd255 --- /dev/null +++ b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { + getTrackedGuestInviteEmails, + trackGuestInviteEmails, + untrackGuestInviteEmail, +} from './guest-invite-tracking' + +const slug = 'test-org' + +describe('guest-invite-tracking', () => { + afterEach(() => { + window.sessionStorage.clear() + }) + + it('tracks and reads guest invite emails for an org', () => { + trackGuestInviteEmails(slug, ['Guest@Example.com']) + + expect(getTrackedGuestInviteEmails(slug)).toEqual(new Set(['guest@example.com'])) + }) + + it('removes tracked guest invite emails', () => { + trackGuestInviteEmails(slug, ['guest@example.com']) + untrackGuestInviteEmail(slug, 'guest@example.com') + + expect(getTrackedGuestInviteEmails(slug)).toEqual(new Set()) + }) +}) diff --git a/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts new file mode 100644 index 00000000000..3283a3ebc39 --- /dev/null +++ b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts @@ -0,0 +1,51 @@ +const guestInviteStorageKey = (slug: string) => `supabase-guest-invites:${slug}` + +function readGuestInviteEmails(slug: string): Set { + if (typeof window === 'undefined') return new Set() + + try { + const raw = window.sessionStorage.getItem(guestInviteStorageKey(slug)) + if (!raw) return new Set() + + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return new Set() + + return new Set(parsed.filter((email): email is string => typeof email === 'string')) + } catch { + return new Set() + } +} + +function writeGuestInviteEmails(slug: string, emails: Set) { + if (typeof window === 'undefined') return + + if (emails.size === 0) { + window.sessionStorage.removeItem(guestInviteStorageKey(slug)) + return + } + + window.sessionStorage.setItem(guestInviteStorageKey(slug), JSON.stringify([...emails])) +} + +export function getTrackedGuestInviteEmails(slug: string | undefined) { + if (!slug) return new Set() + return readGuestInviteEmails(slug) +} + +export function trackGuestInviteEmails(slug: string, emails: string[]) { + const normalizedEmails = emails.map((email) => email.trim().toLowerCase()).filter(Boolean) + if (normalizedEmails.length === 0) return + + const tracked = readGuestInviteEmails(slug) + normalizedEmails.forEach((email) => tracked.add(email)) + writeGuestInviteEmails(slug, tracked) +} + +export function untrackGuestInviteEmail(slug: string, email: string) { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) return + + const tracked = readGuestInviteEmails(slug) + tracked.delete(normalizedEmail) + writeGuestInviteEmails(slug, tracked) +} diff --git a/apps/studio/data/organization-members/organization-invitation-create-mutation.ts b/apps/studio/data/organization-members/organization-invitation-create-mutation.ts index cf01c9dd7e4..1be7be3ff01 100644 --- a/apps/studio/data/organization-members/organization-invitation-create-mutation.ts +++ b/apps/studio/data/organization-members/organization-invitation-create-mutation.ts @@ -3,6 +3,7 @@ import { components } from 'api-types' import { toast } from 'sonner' import { organizationKeys } from './keys' +import { trackGuestInviteEmails } from '@/components/interfaces/TemporaryAccess/guest-invite-tracking' import type { PendingInvitationAccessGrant } from '@/components/interfaces/TemporaryAccess/TemporaryAccess.types' import { handleError, post } from '@/data/fetchers' import { organizationKeys as organizationKeysV1 } from '@/data/organizations/keys' @@ -66,7 +67,11 @@ export const useOrganizationCreateInvitationMutation = ({ >({ mutationFn: (vars) => createOrganizationInvitation(vars), async onSuccess(data, variables, context) { - const { slug } = variables + const { slug, emails, pendingAccessGrant } = variables + + if (pendingAccessGrant && emails.length > 0) { + trackGuestInviteEmails(slug, emails) + } await Promise.all([ queryClient.invalidateQueries({ queryKey: organizationKeys.rolesV2(slug) }), diff --git a/apps/studio/data/organizations/organization-members-query.ts b/apps/studio/data/organizations/organization-members-query.ts index 8ef392d46f7..f5b4a56de40 100644 --- a/apps/studio/data/organizations/organization-members-query.ts +++ b/apps/studio/data/organizations/organization-members-query.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { organizationKeys } from './keys' +import { getTrackedGuestInviteEmails } from '@/components/interfaces/TemporaryAccess/guest-invite-tracking' import type { components } from '@/data/api' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' @@ -15,6 +16,8 @@ export interface OrganizationMember extends Member { invited_id?: number /** Present on pending invites when the platform API returns scoped projects. */ invited_role_scoped_projects?: string[] + /** Set when this pending invite was sent as External collaborator from this browser session. */ + invited_is_external_collaborator?: boolean } export async function getOrganizationMembers( @@ -38,8 +41,10 @@ export async function getOrganizationMembers( if (orgInvitesError) handleError(orgInvitesError) // Remap invite data to look like existing members data + const guestInviteEmails = getTrackedGuestInviteEmails(slug) const invitedMembers = orgInvites.invitations.map((invite) => { const inviteWithScope = invite as typeof invite & { role_scoped_projects?: string[] } + const normalizedEmail = invite.invited_email.trim().toLowerCase() const member = { invited_at: invite.invited_at, invited_id: invite.id, @@ -49,6 +54,7 @@ export async function getOrganizationMembers( ...(inviteWithScope.role_scoped_projects?.length ? { invited_role_scoped_projects: inviteWithScope.role_scoped_projects } : {}), + ...(guestInviteEmails.has(normalizedEmail) ? { invited_is_external_collaborator: true } : {}), } return { ...member, role_ids: [invite.role_id] } })