mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
fix
This commit is contained in:
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={{
|
||||
|
||||
+71
-9
@@ -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)
|
||||
})
|
||||
|
||||
+59
-15
@@ -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] }
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user