This commit is contained in:
Danny White committed 2026-06-25 17:55:08 -06:00
1 parent 96a292e2b9
commit 17dfdfc0fe
14 files changed
+401 -101

No files matched your search

@@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { UserPlus } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useForm, type FieldErrors } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
@@ -38,6 +38,7 @@ import {
buildSsoPayload,
categorizeInviteEmails,
emailSchema,
getInviteFormErrorMessage,
parseEmails,
} from './InviteMemberButton.utils'
import { ROLE_DESCRIPTIONS } from './Roles.constants'
@@ -60,7 +61,6 @@ import {
EXTERNAL_COLLABORATOR_ROLE_NAME,
isExternalCollaboratorRole,
resolveExternalCollaboratorInviteRole,
resolveExternalCollaboratorInviteRoleForProject,
validateGuestAccessGrants,
} from '@/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils'
import { TemporaryAccessInviteGrantSection } from '@/components/interfaces/TemporaryAccess/TemporaryAccessInviteGrantSection'
@@ -167,7 +167,7 @@ export const InviteMemberButton = () => {
return 'You need additional permissions to invite members'
}
if (isRolesSuccess && !externalCollaboratorInviteRole) {
return 'External collaborator invites are not available for this organization'
return 'Temporary guest invites are not available for this organization'
}
return undefined
}, [canInviteMembers, externalCollaboratorInviteRole, isRolesSuccess])
@@ -207,7 +207,7 @@ export const InviteMemberButton = () => {
if (!data.projectRef) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A project must be selected for external collaborators',
message: 'A project must be selected for temporary guests',
path: ['projectRef'],
})
}
@@ -299,11 +299,11 @@ export const InviteMemberButton = () => {
const ssoPayload = buildSsoPayload(values.requireSso)
const inviteRoleId = isGuest
? resolveExternalCollaboratorInviteRoleForProject(values.projectRef, allRoles)?.roleId
? resolveExternalCollaboratorInviteRole(allRoles)?.roleId
: Number(values.role)
if (isGuest && !inviteRoleId) {
toast.error('External collaborator invites are not available for this organization')
toast.error('Temporary guest invites are not available for this organization')
return
}
@@ -362,6 +362,13 @@ export const InviteMemberButton = () => {
}
}
const onInvalidInvite = (errors: FieldErrors<InviteMemberFormValues>) => {
const message = getInviteFormErrorMessage(errors)
if (message) toast.error(message)
}
const submitInvite = form.handleSubmit(onInviteMember, onInvalidInvite)
useEffect(() => {
if (isRolesSuccess && isOpen) {
const developerRoleId = orgScopedRoles
@@ -434,11 +441,7 @@ export const InviteMemberButton = () => {
</SheetHeader>
<SheetSection className="grow overflow-auto p-0">
<Form {...form}>
<form
id="organization-invitation"
className="flex flex-col"
onSubmit={form.handleSubmit(onInviteMember)}
>
<form id="organization-invitation" className="flex flex-col" onSubmit={submitInvite}>
<SheetSection className="flex flex-col gap-y-4">
<FormField
name="email"
@@ -681,7 +684,7 @@ export const InviteMemberButton = () => {
</Button>
<Shortcut
id={SHORTCUT_IDS.ORG_TEAM_INVITE_SUBMIT}
onTrigger={() => form.handleSubmit(onInviteMember)()}
onTrigger={() => submitInvite()}
options={{ enabled: isOpen && !isInviting }}
side="top"
>
@@ -65,6 +65,22 @@ export type BatchInvitationResult = {
failed: BatchInvitationFailure[]
}
export function getInviteFormErrorMessage(errors: {
email?: { message?: string }
role?: { message?: string }
projectRef?: { message?: string }
guestAccess?: { message?: string }
accessScope?: { message?: string }
}): string | undefined {
return (
errors.guestAccess?.message ??
errors.projectRef?.message ??
errors.accessScope?.message ??
errors.role?.message ??
errors.email?.message
)
}
export function categorizeInviteEmails(
emails: string[],
members: OrganizationMember[]
@@ -10,6 +10,9 @@ import {
ScrollArea,
TableCell,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
@@ -19,9 +22,11 @@ import {
getMemberAccessScopeDisplay,
getMemberJitGrantSummary,
getMemberRoleNames,
getPendingGuestAccessTooltip,
isExternalCollaboratorMember,
} from './TemporaryAccessMember.utils'
import { useIsJitDbAccessEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ClockFading } from '@/components/ui/icons/ClockFading'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { ProfileImage } from '@/components/ui/ProfileImage'
import type { OrgMemberJitGrantSummary } from '@/data/jit-db-access/use-org-jit-grants-query'
@@ -66,6 +71,7 @@ export const MemberRow = ({ member, grantsByUserId }: MemberRowProps) => {
() => getMemberRoleNames(member, roles, { isJitGuest: isExternalCollaborator }),
[member, roles, isExternalCollaborator]
)
const isPendingGuest = isInvitedUser && isExternalCollaborator
const accessScope = useMemo(
() =>
getMemberAccessScopeDisplay({
@@ -74,11 +80,43 @@ export const MemberRow = ({ member, grantsByUserId }: MemberRowProps) => {
orgProjects,
hasProjectScopedRoles,
jitSummary,
isPendingExternalCollaborator: isPendingGuest,
isExternalCollaborator,
}),
[member, roles, orgProjects, hasProjectScopedRoles, jitSummary]
[
member,
roles,
orgProjects,
hasProjectScopedRoles,
jitSummary,
isPendingGuest,
isExternalCollaborator,
]
)
const profileImageUrl = undefined
const pendingGuestAccessTooltip = useMemo(
() =>
isPendingGuest
? getPendingGuestAccessTooltip(member.invited_pending_access_grant)
: undefined,
[isPendingGuest, member.invited_pending_access_grant]
)
const avatarPlaceholder = (
<div
className={cn(
'w-[32px] h-[32px] md:w-[40px] md:h-[40px]',
'bg-surface-100 border border-overlay rounded-full text-foreground-lighter flex items-center justify-center'
)}
>
{isPendingGuest ? (
<ClockFading size={20} strokeWidth={1.5} />
) : (
<User size={20} strokeWidth={1.5} />
)}
</div>
)
return (
<TableRow>
@@ -89,14 +127,18 @@ export const MemberRow = ({ member, grantsByUserId }: MemberRowProps) => {
src={profileImageUrl}
className="border rounded-full w-[32px] h-[32px] md:w-[40px] md:h-[40px]"
placeholder={
<div
className={cn(
'w-[32px] h-[32px] md:w-[40px] md:h-[40px]',
'bg-surface-100 border border-overlay rounded-full text-foreground-lighter flex items-center justify-center'
)}
>
<User size={20} strokeWidth={1.5} />
</div>
isPendingGuest ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{avatarPlaceholder}</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
{pendingGuestAccessTooltip}
</TooltipContent>
</Tooltip>
) : (
avatarPlaceholder
)
}
/>
<div className="min-w-0">
@@ -110,7 +152,6 @@ export const MemberRow = ({ member, grantsByUserId }: MemberRowProps) => {
</Badge>
)}
{member.is_sso_user && <Badge variant="default">SSO</Badge>}
{isExternalCollaborator && <Badge>Guest</Badge>}
{(member.metadata as any)?.origin && (
<PartnerIcon
organization={{
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
import {
formatMemberDatabaseExpiryMeta,
formatPendingGuestAccessExpiryMeta,
getMemberAccessScopeDisplay,
getMemberJitGrantSummary,
getMemberRoleNames,
getPendingGuestAccessTooltip,
isExternalCollaboratorMember,
resolveOrganizationRoleDisplayName,
} from './TemporaryAccessMember.utils'
@@ -51,12 +53,12 @@ describe('TemporaryAccessMember.utils', () => {
).toEqual(['Read-only'])
})
it('shows External collaborator for JIT guest members', () => {
it('shows Temporary guest for JIT guest members', () => {
expect(
getMemberRoleNames({ ...member, role_ids: [301] } as OrganizationMember, roles, {
isJitGuest: true,
})
).toEqual(['External collaborator'])
).toEqual(['Temporary guest'])
})
it('detects pending external collaborator invites with project-scoped read-only role', () => {
@@ -82,7 +84,7 @@ describe('TemporaryAccessMember.utils', () => {
expect(isExternalCollaboratorMember(pendingMember, teamRoles)).toBe(true)
expect(getMemberRoleNames(pendingMember, teamRoles, { isJitGuest: true })).toEqual([
'External collaborator',
'Temporary guest',
])
})
@@ -235,6 +237,57 @@ describe('TemporaryAccessMember.utils', () => {
expect(display.isOrgWide).toBe(true)
})
it('formats pending guest expiry metadata', () => {
expect(
formatPendingGuestAccessExpiryMeta({
project_ref: 'proj-a',
roles: [{ role: 'supabase_read_only_user', expires_after_seconds: 3600 }],
})
).toBe('1 hour expiry')
})
it('builds pending guest avatar tooltip copy', () => {
expect(
getPendingGuestAccessTooltip({
project_ref: 'proj-a',
roles: [{ role: 'supabase_read_only_user', expires_after_seconds: 3600 }],
})
).toBe('This guest has not joined yet. 1 hour access starts when they accept the invitation.')
})
it('shows pending guest expiry in access column subtext', () => {
const display = getMemberAccessScopeDisplay({
member: {
invited_id: 1,
role_ids: [301],
invited_pending_access_grant: {
project_ref: 'proj-a',
roles: [{ role: 'supabase_read_only_user', expires_after_seconds: 3600 }],
},
} as OrganizationMember,
roles: {
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,
orgProjects: [{ ref: 'proj-a', name: 'Alpha' }],
hasProjectScopedRoles: true,
jitSummary: null,
isPendingExternalCollaborator: true,
})
expect(display.expiryMeta).toBe('1 hour expiry')
})
it('formats database expiry metadata from jit grants', () => {
const jitSummary = getMemberJitGrantSummary(
{ gotrue_id: 'user-1', role_ids: [4] } as OrganizationMember,
@@ -256,4 +309,68 @@ describe('TemporaryAccessMember.utils', () => {
expect(jitSummary).not.toBeNull()
expect(formatMemberDatabaseExpiryMeta(jitSummary!)).toMatch(/Database access expires in \d+h/)
})
it('does not show jit expiry subtext for org members with stale grants', () => {
const display = getMemberAccessScopeDisplay({
member: { gotrue_id: 'owner-1', role_ids: [1] } as OrganizationMember,
roles: {
org_scoped_roles: [
{ id: 1, name: 'Owner', base_role_id: 1, description: null, projects: [] },
],
project_scoped_roles: [],
} as never,
orgProjects: [],
hasProjectScopedRoles: true,
jitSummary: {
grants: [
{
projectRef: 'proj-a',
projectName: 'Alpha',
userRoles: [{ role: 'postgres', expires_at: Math.floor(Date.now() / 1000) - 3600 }],
status: { active: 0, expired: 1, activeIp: 0, expiredIp: 0 },
},
],
status: { active: 0, expired: 1, activeIp: 0, expiredIp: 0 },
},
isExternalCollaborator: false,
})
expect(display.expiryMeta).toBeNull()
})
it('shows jit expiry subtext for accepted temporary guests', () => {
const display = getMemberAccessScopeDisplay({
member: { gotrue_id: 'guest-1', role_ids: [301] } as OrganizationMember,
roles: {
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,
orgProjects: [{ ref: 'proj-a', name: 'Alpha' }],
hasProjectScopedRoles: true,
jitSummary: {
grants: [
{
projectRef: 'proj-a',
projectName: 'Alpha',
userRoles: [{ role: 'postgres', expires_at: Math.floor(Date.now() / 1000) - 3600 }],
status: { active: 0, expired: 1, activeIp: 0, expiredIp: 0 },
},
],
status: { active: 0, expired: 1, activeIp: 0, expiredIp: 0 },
},
isExternalCollaborator: true,
})
expect(display.expiryMeta).toBe('Database access expired')
})
})
@@ -1,6 +1,7 @@
import dayjs from 'dayjs'
import { ALL_PROJECTS_ACCESS_SCOPE_LABEL } from './TeamAccessScope.utils'
import type { PendingInvitationAccessGrant } from '@/components/interfaces/TemporaryAccess/TemporaryAccess.types'
import { EXTERNAL_COLLABORATOR_ROLE_NAME } from '@/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils'
import type { OrgMemberJitGrantSummary } from '@/data/jit-db-access/use-org-jit-grants-query'
import type {
@@ -238,18 +239,89 @@ export function formatMemberDatabaseExpiryMeta(
return `Database access expires ${soonestExpiry.format('D MMM YYYY, h:mma')}`
}
function formatRelativeExpirySubtext(seconds: number): string {
const presetLabels: Record<number, string> = {
[60 * 60]: '1 hour expiry',
[60 * 60 * 24]: '1 day expiry',
[60 * 60 * 24 * 7]: '7 day expiry',
[60 * 60 * 24 * 30]: '30 day expiry',
}
if (presetLabels[seconds]) return presetLabels[seconds]
if (seconds < 60 * 60) {
const minutes = Math.max(1, Math.round(seconds / 60))
return `${minutes} minute${minutes === 1 ? '' : 's'} expiry`
}
if (seconds < 60 * 60 * 24) {
const hours = Math.max(1, Math.round(seconds / 3600))
return `${hours} hour${hours === 1 ? '' : 's'} expiry`
}
const days = Math.max(1, Math.round(seconds / (60 * 60 * 24)))
return `${days} day${days === 1 ? '' : 's'} expiry`
}
export function formatPendingGuestAccessExpiryMeta(
pendingAccessGrant: PendingInvitationAccessGrant | undefined
): string | null {
const roles = pendingAccessGrant?.roles ?? []
const relativeDurations = roles
.map((role) => role.expires_after_seconds)
.filter((seconds): seconds is number => typeof seconds === 'number')
const absoluteExpiries = roles
.map((role) => role.expires_at)
.filter((expiresAt): expiresAt is number => typeof expiresAt === 'number')
if (relativeDurations.length > 0) {
return formatRelativeExpirySubtext(Math.min(...relativeDurations))
}
if (absoluteExpiries.length > 0) {
const soonestExpiry = dayjs.unix(Math.min(...absoluteExpiries))
if (soonestExpiry.isValid()) {
return `Expires ${soonestExpiry.format('D MMM YYYY')}`
}
}
return null
}
export function getPendingGuestAccessTooltip(
pendingAccessGrant: PendingInvitationAccessGrant | undefined
): string {
const expirySubtext = formatPendingGuestAccessExpiryMeta(pendingAccessGrant)
if (expirySubtext?.endsWith(' expiry')) {
const duration = expirySubtext.replace(/ expiry$/, '')
return `This guest has not joined yet. ${duration} access starts when they accept the invitation.`
}
if (expirySubtext?.startsWith('Expires ')) {
return `This guest has not joined yet. Access is set to ${expirySubtext.toLowerCase()} once they accept.`
}
return 'This guest has not joined yet. Database access starts when they accept the invitation.'
}
export function getMemberAccessScopeDisplay({
member,
roles,
orgProjects,
hasProjectScopedRoles,
jitSummary,
isPendingExternalCollaborator = false,
isExternalCollaborator = false,
}: {
member: OrganizationMember
roles: OrganizationRolesData | undefined
orgProjects: Array<{ ref: string; name: string }>
hasProjectScopedRoles: boolean
jitSummary: ReturnType<typeof getMemberJitGrantSummary>
isPendingExternalCollaborator?: boolean
/** Accepted temporary guests only — org members may have stale JIT rows from testing. */
isExternalCollaborator?: boolean
}): MemberAccessScopeDisplay {
const orgScopedRoles = roles?.org_scoped_roles ?? []
const projectScopedRoles = roles?.project_scoped_roles ?? []
@@ -296,6 +368,10 @@ export function getMemberAccessScopeDisplay({
label,
projectNames: [...projectNames],
isOrgWide,
expiryMeta: jitSummary ? formatMemberDatabaseExpiryMeta(jitSummary) : null,
expiryMeta: isPendingExternalCollaborator
? formatPendingGuestAccessExpiryMeta(member.invited_pending_access_grant)
: isExternalCollaborator && jitSummary
? formatMemberDatabaseExpiryMeta(jitSummary)
: null,
}
}
@@ -42,7 +42,7 @@ describe('TemporaryAccessInvite.utils', () => {
).toEqual({ roleId: 3, usesDeveloperFallback: true })
})
test('resolveExternalCollaboratorInviteRole uses project-scoped Read-only when present', () => {
test('resolveExternalCollaboratorInviteRole falls back to org Developer when org Read-only is absent', () => {
expect(
resolveExternalCollaboratorInviteRole({
org_scoped_roles: [
@@ -52,7 +52,7 @@ describe('TemporaryAccessInvite.utils', () => {
{ id: 104, name: 'Read-only', base_role_id: 4, description: null, projects: [] },
],
})
).toEqual({ roleId: 104, usesDeveloperFallback: false })
).toEqual({ roleId: 3, usesDeveloperFallback: true })
})
test('createInviteGuestGrantDraft pre-enables read-only with 1h expiry', () => {
@@ -16,10 +16,10 @@ import type { OrganizationRolesResponse } from '@/data/organization-members/orga
/** Form-only role id — maps to Read-only org role + pending JIT grant on invite. */
export const EXTERNAL_COLLABORATOR_ROLE_ID = '__external_collaborator__' as const
export const EXTERNAL_COLLABORATOR_ROLE_NAME = 'External collaborator'
export const EXTERNAL_COLLABORATOR_ROLE_NAME = 'Temporary guest'
export const EXTERNAL_COLLABORATOR_ROLE_DESCRIPTION =
'Project-scoped guest with minimal Studio access and temporary database connections.'
'Project-scoped temporary guest with limited Studio access and time-bound database connections.'
export type ExternalCollaboratorInviteRole = {
roleId: number
@@ -28,46 +28,22 @@ export type ExternalCollaboratorInviteRole = {
}
/**
* Platform role sent on external collaborator invites.
* Prefers Read-only (org or project-scoped). Free/Pro orgs may omit Read-only from /roles;
* falls back to Developer + project scope while JIT grant carries database access.
* Platform role sent on temporary guest invites.
* Invitations accept org-scoped role ids plus role_scoped_projects — not project-scoped role ids.
* Free/Pro orgs may omit Read-only from /roles; falls back to Developer + project scope.
*/
function isReadOnlyProjectScopedRole(
projectRole: OrganizationRolesResponse['project_scoped_roles'][number],
orgScopedRoles: OrganizationRolesResponse['org_scoped_roles']
) {
if (projectRole.name === 'Read-only' || projectRole.name.startsWith('Read-only_')) {
return true
}
const baseRole = orgScopedRoles.find((role) => role.id === projectRole.base_role_id)
return baseRole?.name === 'Read-only'
}
export function resolveExternalCollaboratorInviteRole(
allRoles: OrganizationRolesResponse | undefined
): ExternalCollaboratorInviteRole | undefined {
if (!allRoles) return undefined
const orgScopedRoles = allRoles.org_scoped_roles ?? []
const projectScopedRoles = allRoles.project_scoped_roles ?? []
const orgReadOnly = orgScopedRoles.find((role) => role.name === 'Read-only')
if (orgReadOnly) {
return { roleId: orgReadOnly.id, usesDeveloperFallback: false }
}
const projectReadOnly = projectScopedRoles.find((role) => role.name === 'Read-only')
if (projectReadOnly) {
return { roleId: projectReadOnly.id, usesDeveloperFallback: false }
}
for (const projectRole of projectScopedRoles) {
if (isReadOnlyProjectScopedRole(projectRole, orgScopedRoles)) {
return { roleId: projectRole.id, usesDeveloperFallback: false }
}
}
const orgDeveloper = orgScopedRoles.find((role) => role.name === 'Developer')
if (orgDeveloper) {
return { roleId: orgDeveloper.id, usesDeveloperFallback: true }
@@ -76,28 +52,6 @@ export function resolveExternalCollaboratorInviteRole(
return undefined
}
/** Prefer the project-scoped Read-only role for a guest invite so pending rows are identifiable in Team. */
export function resolveExternalCollaboratorInviteRoleForProject(
projectRef: string,
allRoles: OrganizationRolesResponse | undefined
): ExternalCollaboratorInviteRole | undefined {
if (!allRoles || !projectRef) {
return resolveExternalCollaboratorInviteRole(allRoles)
}
const orgScopedRoles = allRoles.org_scoped_roles ?? []
const projectScopedRoles = allRoles.project_scoped_roles ?? []
for (const projectRole of projectScopedRoles) {
if (!projectRole.projects.some((project) => project.ref === projectRef)) continue
if (!isReadOnlyProjectScopedRole(projectRole, orgScopedRoles)) continue
return { roleId: projectRole.id, usesDeveloperFallback: false }
}
return resolveExternalCollaboratorInviteRole(allRoles)
}
const EXPIRY_MODE_TO_SECONDS: Record<'1h' | '1d' | '7d' | '30d', number> = {
'1h': 60 * 60,
'1d': 60 * 60 * 24,
@@ -193,7 +147,7 @@ export function validateGuestAccessGrants(grants: TemporaryAccessRoleGrantDraft[
for (const grant of enabledGrants) {
if (grant.expiryMode === 'never') {
return 'External collaborators must have an expiry.'
return 'Temporary guests must have an expiry.'
}
if (grant.hasExpiry && grant.expiryMode === 'custom' && !grant.expiry) {
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
getTrackedGuestInvite,
getTrackedGuestInviteEmails,
trackGuestInviteEmails,
untrackGuestInviteEmail,
@@ -19,6 +20,18 @@ describe('guest-invite-tracking', () => {
expect(getTrackedGuestInviteEmails(slug)).toEqual(new Set(['guest@example.com']))
})
it('tracks guest invite emails with pending access grant metadata', () => {
trackGuestInviteEmails(slug, ['Guest@Example.com'], {
project_ref: 'proj-a',
roles: [{ role: 'supabase_read_only_user', expires_after_seconds: 3600 }],
})
expect(getTrackedGuestInvite(slug, 'guest@example.com')?.pendingAccessGrant).toEqual({
project_ref: 'proj-a',
roles: [{ role: 'supabase_read_only_user', expires_after_seconds: 3600 }],
})
})
it('removes tracked guest invite emails', () => {
trackGuestInviteEmails(slug, ['guest@example.com'])
untrackGuestInviteEmail(slug, 'guest@example.com')
@@ -1,51 +1,88 @@
import type { PendingInvitationAccessGrant } from './TemporaryAccess.types'
type TrackedGuestInvite = {
pendingAccessGrant?: PendingInvitationAccessGrant
}
const guestInviteStorageKey = (slug: string) => `supabase-guest-invites:${slug}`
function readGuestInviteEmails(slug: string): Set<string> {
if (typeof window === 'undefined') return new Set()
function readGuestInvites(slug: string): Map<string, TrackedGuestInvite> {
if (typeof window === 'undefined') return new Map()
try {
const raw = window.sessionStorage.getItem(guestInviteStorageKey(slug))
if (!raw) return new Set()
if (!raw) return new Map()
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((email): email is string => typeof email === 'string'))
if (Array.isArray(parsed)) {
return new Map(
parsed
.filter((email): email is string => typeof email === 'string')
.map((email) => [email.trim().toLowerCase(), {}])
)
}
if (typeof parsed !== 'object' || parsed === null) return new Map()
return new Map(
Object.entries(parsed).map(([email, value]) => {
const invite = value as TrackedGuestInvite
return [email.trim().toLowerCase(), invite ?? {}]
})
)
} catch {
return new Set()
return new Map()
}
}
function writeGuestInviteEmails(slug: string, emails: Set<string>) {
function writeGuestInvites(slug: string, invites: Map<string, TrackedGuestInvite>) {
if (typeof window === 'undefined') return
if (emails.size === 0) {
if (invites.size === 0) {
window.sessionStorage.removeItem(guestInviteStorageKey(slug))
return
}
window.sessionStorage.setItem(guestInviteStorageKey(slug), JSON.stringify([...emails]))
window.sessionStorage.setItem(
guestInviteStorageKey(slug),
JSON.stringify(Object.fromEntries(invites))
)
}
export function getTrackedGuestInviteEmails(slug: string | undefined) {
if (!slug) return new Set<string>()
return readGuestInviteEmails(slug)
return new Set(readGuestInvites(slug).keys())
}
export function trackGuestInviteEmails(slug: string, emails: string[]) {
export function getTrackedGuestInvite(
slug: string | undefined,
email: string | undefined
): TrackedGuestInvite | undefined {
if (!slug || !email) return undefined
return readGuestInvites(slug).get(email.trim().toLowerCase())
}
export function trackGuestInviteEmails(
slug: string,
emails: string[],
pendingAccessGrant?: PendingInvitationAccessGrant
) {
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)
const tracked = readGuestInvites(slug)
normalizedEmails.forEach((email) => {
tracked.set(email, { pendingAccessGrant })
})
writeGuestInvites(slug, tracked)
}
export function untrackGuestInviteEmail(slug: string, email: string) {
const normalizedEmail = email.trim().toLowerCase()
if (!normalizedEmail) return
const tracked = readGuestInviteEmails(slug)
const tracked = readGuestInvites(slug)
tracked.delete(normalizedEmail)
writeGuestInviteEmails(slug, tracked)
writeGuestInvites(slug, tracked)
}
@@ -0,0 +1,11 @@
import { createLucideIcon } from 'lucide-react'
/** Lucide `clock-fading` — not yet in studio's lucide-react pin; matches lucide.dev/icons/clock-fading */
export const ClockFading = createLucideIcon('ClockFading', [
['path', { d: 'M12 2a10 10 0 0 1 7.38 16.75' }],
['path', { d: 'M12 6v6l4 2' }],
['path', { d: 'M2.5 8.875a10 10 0 0 0-.5 3' }],
['path', { d: 'M2.83 16a10 10 0 0 0 2.43 3.4' }],
['path', { d: 'M4.636 5.235a10 10 0 0 1 .891-.857' }],
['path', { d: 'M8.644 21.42a10 10 0 0 0 7.631-.38' }],
])
@@ -70,7 +70,7 @@ export const useOrganizationCreateInvitationMutation = ({
const { slug, emails, pendingAccessGrant } = variables
if (pendingAccessGrant && emails.length > 0) {
trackGuestInviteEmails(slug, emails)
trackGuestInviteEmails(slug, emails, pendingAccessGrant)
}
await Promise.all([
@@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { organizationKeys } from './keys'
import { getTrackedGuestInviteEmails } from '@/components/interfaces/TemporaryAccess/guest-invite-tracking'
import { getTrackedGuestInvite } from '@/components/interfaces/TemporaryAccess/guest-invite-tracking'
import type { PendingInvitationAccessGrant } from '@/components/interfaces/TemporaryAccess/TemporaryAccess.types'
import type { components } from '@/data/api'
import { get, handleError } from '@/data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
@@ -18,6 +19,8 @@ export interface OrganizationMember extends Member {
invited_role_scoped_projects?: string[]
/** Set when this pending invite was sent as External collaborator from this browser session. */
invited_is_external_collaborator?: boolean
/** Guest database access configured at invite time (session storage until platform API supports it). */
invited_pending_access_grant?: PendingInvitationAccessGrant
}
export async function getOrganizationMembers(
@@ -41,10 +44,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 trackedGuestInvite = getTrackedGuestInvite(slug, normalizedEmail)
const member = {
invited_at: invite.invited_at,
invited_id: invite.id,
@@ -54,7 +57,14 @@ 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 } : {}),
...(trackedGuestInvite
? {
invited_is_external_collaborator: true,
...(trackedGuestInvite.pendingAccessGrant
? { invited_pending_access_grant: trackedGuestInvite.pendingAccessGrant }
: {}),
}
: {}),
}
return { ...member, role_ids: [invite.role_id] }
})
@@ -356,7 +356,7 @@ describe('InviteMemberButton (network)', () => {
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
test('shows guest database access fields when External collaborator role is selected', async () => {
test('shows guest database access fields when Temporary guest role is selected', async () => {
setupMocks()
addAPIMock({
@@ -7,6 +7,7 @@ import {
buildSsoPayload,
categorizeInviteEmails,
emailSchema,
getInviteFormErrorMessage,
MAX_BATCH_INVITE_SIZE,
parseEmails,
} from '@/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils'
@@ -212,6 +213,27 @@ describe('buildSsoPayload', () => {
})
})
describe('getInviteFormErrorMessage', () => {
test('prioritizes guestAccess errors', () => {
expect(
getInviteFormErrorMessage({
email: { message: 'Email required' },
guestAccess: { message: 'Select at least one Postgres role.' },
})
).toBe('Select at least one Postgres role.')
})
test('falls back through project, access scope, role, and email', () => {
expect(getInviteFormErrorMessage({ projectRef: { message: 'Pick a project' } })).toBe(
'Pick a project'
)
expect(getInviteFormErrorMessage({ role: { message: 'Role is required' } })).toBe(
'Role is required'
)
expect(getInviteFormErrorMessage({})).toBeUndefined()
})
})
function makeEmailList(count: number): string {
return Array.from({ length: count }, (_, i) => `user${i + 1}@example.com`).join(', ')
}