From 17dfdfc0fe143a30deaef2fb42ce9ea4e4cbca1f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:55:08 -0600 Subject: [PATCH] polish --- .../TeamSettings/InviteMemberButton.tsx | 27 ++-- .../TeamSettings/InviteMemberButton.utils.ts | 16 +++ .../Organization/TeamSettings/MemberRow.tsx | 61 +++++++-- .../TemporaryAccessMember.utils.test.ts | 123 +++++++++++++++++- .../TemporaryAccessMember.utils.ts | 78 ++++++++++- .../TemporaryAccessInvite.utils.test.ts | 4 +- .../TemporaryAccessInvite.utils.ts | 58 +-------- .../guest-invite-tracking.test.ts | 13 ++ .../TemporaryAccess/guest-invite-tracking.ts | 69 +++++++--- .../components/ui/icons/ClockFading.tsx | 11 ++ ...organization-invitation-create-mutation.ts | 2 +- .../organization-members-query.ts | 16 ++- .../InviteMemberButton.network.test.tsx | 2 +- .../InviteMemberButton.utils.test.ts | 22 ++++ 14 files changed, 401 insertions(+), 101 deletions(-) create mode 100644 apps/studio/components/ui/icons/ClockFading.tsx diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 85cd6ab30c0..c8293908f3b 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -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) => { + 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 = () => {
- + { form.handleSubmit(onInviteMember)()} + onTrigger={() => submitInvite()} options={{ enabled: isOpen && !isInviting }} side="top" > diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts index de95599f435..3280c45ab03 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts @@ -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[] diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index ac4e5a0b8ea..7ebcfa2b7bf 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -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 = ( +
+ {isPendingGuest ? ( + + ) : ( + + )} +
+ ) return ( @@ -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={ -
- -
+ isPendingGuest ? ( + + + {avatarPlaceholder} + + + {pendingGuestAccessTooltip} + + + ) : ( + avatarPlaceholder + ) } />
@@ -110,7 +152,6 @@ export const MemberRow = ({ member, grantsByUserId }: MemberRowProps) => { )} {member.is_sso_user && SSO} - {isExternalCollaborator && Guest} {(member.metadata as any)?.origin && ( { ).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') + }) }) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts index abadc521b83..c9bc6f46d2a 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TemporaryAccessMember.utils.ts @@ -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 = { + [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 + 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, } } diff --git a/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.test.ts b/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.test.ts index 4961114fd90..893ee3d73de 100644 --- a/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.test.ts +++ b/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.test.ts @@ -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', () => { diff --git a/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.ts b/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.ts index 61f6f9b62da..7f0ca3c5ea0 100644 --- a/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.ts +++ b/apps/studio/components/interfaces/TemporaryAccess/TemporaryAccessInvite.utils.ts @@ -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) { diff --git a/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts index 380b09fd255..61de56894f8 100644 --- a/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts +++ b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.test.ts @@ -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') diff --git a/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts index 3283a3ebc39..bb34e1958a7 100644 --- a/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts +++ b/apps/studio/components/interfaces/TemporaryAccess/guest-invite-tracking.ts @@ -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 { - if (typeof window === 'undefined') return new Set() +function readGuestInvites(slug: string): Map { + 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) { +function writeGuestInvites(slug: string, invites: Map) { 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() - 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) } diff --git a/apps/studio/components/ui/icons/ClockFading.tsx b/apps/studio/components/ui/icons/ClockFading.tsx new file mode 100644 index 00000000000..7c66e1ef31c --- /dev/null +++ b/apps/studio/components/ui/icons/ClockFading.tsx @@ -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' }], +]) 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 1be7be3ff01..1fe92ff1ac1 100644 --- a/apps/studio/data/organization-members/organization-invitation-create-mutation.ts +++ b/apps/studio/data/organization-members/organization-invitation-create-mutation.ts @@ -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([ diff --git a/apps/studio/data/organizations/organization-members-query.ts b/apps/studio/data/organizations/organization-members-query.ts index f5b4a56de40..6f37ddbf5f5 100644 --- a/apps/studio/data/organizations/organization-members-query.ts +++ b/apps/studio/data/organizations/organization-members-query.ts @@ -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] } }) diff --git a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx index b36e4cf447e..507d15c0c05 100644 --- a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx +++ b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx @@ -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({ diff --git a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts index feb89c55dfe..e81819dc32f 100644 --- a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts +++ b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts @@ -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(', ') }