mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
polish
This commit is contained in:
1 parent
96a292e2b9
commit
17dfdfc0fe
14 files changed
+401
-101
No files matched your search
+15
-12
@@ -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"
|
||||
>
|
||||
|
||||
+16
@@ -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={{
|
||||
|
||||
+120
-3
@@ -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')
|
||||
})
|
||||
})
|
||||
+77
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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] }
|
||||
})
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+22
@@ -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(', ')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user