This commit is contained in:
Danny White committed 2026-06-25 17:17:24 -06:00
1 parent c374bb4d68
commit 96a292e2b9
7 files changed
+222 -26

No files matched your search

@@ -110,7 +110,7 @@ export const MemberRow = ({ member, grantsByUserId }: MemberRowProps) => {
</Badge>
)}
{member.is_sso_user && <Badge variant="default">SSO</Badge>}
{isExternalCollaborator && <Badge variant="warning">Guest</Badge>}
{isExternalCollaborator && <Badge>Guest</Badge>}
{(member.metadata as any)?.origin && (
<PartnerIcon
organization={{
@@ -63,6 +63,7 @@ describe('TemporaryAccessMember.utils', () => {
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)
})
@@ -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<string>()
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(
@@ -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())
})
})
@@ -0,0 +1,51 @@
const guestInviteStorageKey = (slug: string) => `supabase-guest-invites:${slug}`
function readGuestInviteEmails(slug: string): Set<string> {
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<string>) {
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<string>()
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)
}
@@ -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) }),
@@ -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] }
})