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] }
})