diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx
index 7371cded9cb..1e8c61f6168 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx
@@ -1,7 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useParams } from 'common'
+import { useIsLoggedIn, useParams } from 'common'
import { MoreVertical, Redo2, Trash } from 'lucide-react'
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
@@ -14,38 +14,42 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { LeaveTeamButton } from './LeaveTeamButton'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
-import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
import { useOrganizationDeleteInvitationMutation } from '@/data/organization-members/organization-invitation-delete-mutation'
-import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
+import type { OrganizationRolesResponse } from '@/data/organization-members/organization-roles-query'
import { useOrganizationMemberDeleteMutation } from '@/data/organizations/organization-member-delete-mutation'
-import {
- useOrganizationMembersQuery,
- type OrganizationMember,
-} from '@/data/organizations/organization-members-query'
-import { usePermissionsQuery } from '@/data/permissions/permissions-query'
-import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
-import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
-import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
+import type { OrganizationMember } from '@/data/organizations/organization-members-query'
+import type { OrganizationBase } from '@/data/organizations/organizations-query'
+import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
+import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
+import type { Permission } from '@/types'
interface MemberActionsProps {
member: OrganizationMember
+ members: OrganizationMember[]
+ allRoles: OrganizationRolesResponse | undefined
+ permissions: Permission[] | undefined
+ selectedOrganization: OrganizationBase | undefined
+ organizationMembersDeletionEnabled: boolean
+ onManageAccess: (member: OrganizationMember) => void
}
-export const MemberActions = ({ member }: MemberActionsProps) => {
+export const MemberActions = ({
+ member,
+ members,
+ allRoles,
+ permissions,
+ selectedOrganization,
+ organizationMembersDeletionEnabled,
+ onManageAccess,
+}: MemberActionsProps) => {
const { slug } = useParams()
const { profile } = useProfile()
- const [showAccessModal, setShowAccessModal] = useState(false)
+ const isLoggedIn = useIsLoggedIn()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
- const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
-
- const { data: selectedOrganization } = useSelectedOrganizationQuery()
- const { data: permissions } = usePermissionsQuery()
- const { data: allRoles } = useOrganizationRolesV2Query({ slug })
- const { data: members } = useOrganizationMembersQuery({ slug })
const memberIsUser = member.gotrue_id == profile?.gotrue_id
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
@@ -66,18 +70,32 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
const roleId = member.role_ids?.[0] ?? -1
const canRemoveMember = member.role_ids.every((id) => rolesRemovable.includes(id))
- const { can: canCreateUserInvites } = useAsyncCheckPermissions(
- PermissionAction.CREATE,
- 'user_invites',
- { resource: { role_id: roleId } }
- )
+ const canCreateUserInvites = useMemo(() => {
+ if (!IS_PLATFORM) return true
+ if (!isLoggedIn) return false
+ if (!permissions) return false
+ return doPermissionsCheck(
+ permissions,
+ PermissionAction.CREATE,
+ 'user_invites',
+ { resource: { role_id: roleId } },
+ selectedOrganization?.slug
+ )
+ }, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
const canResendInvite = canCreateUserInvites && hasOrgRole
- const { can: canDeleteUserInvites } = useAsyncCheckPermissions(
- PermissionAction.DELETE,
- 'user_invites',
- { resource: { role_id: roleId } }
- )
+ const canDeleteUserInvites = useMemo(() => {
+ if (!IS_PLATFORM) return true
+ if (!isLoggedIn) return false
+ if (!permissions) return false
+ return doPermissionsCheck(
+ permissions,
+ PermissionAction.DELETE,
+ 'user_invites',
+ { resource: { role_id: roleId } },
+ selectedOrganization?.slug
+ )
+ }, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
const canRevokeInvite = canDeleteUserInvites && hasOrgRole
const { mutate: deleteOrganizationMember, isPending: isDeletingMember } =
@@ -165,7 +183,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
setShowAccessModal(true)}
+ onClick={() => onManageAccess(member)}
tooltip={{
content: {
side: 'bottom',
@@ -291,12 +309,6 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
{selectedOrganization?.name}?
-
- setShowAccessModal(false)}
- />
>
)
}
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx
index eab49d5d7a6..ead944a213a 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx
@@ -1,7 +1,5 @@
-import { useParams } from 'common'
import { ArrowRight, Check, ChevronRight, User, X } from 'lucide-react'
import Link from 'next/link'
-import { useMemo } from 'react'
import {
Badge,
cn,
@@ -18,34 +16,44 @@ import { isInviteExpired } from '../Organization.utils'
import { MemberActions } from './MemberActions'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { ProfileImage } from '@/components/ui/ProfileImage'
-import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
+import { OrganizationRolesResponse } from '@/data/organization-members/organization-roles-query'
import { OrganizationMember } from '@/data/organizations/organization-members-query'
-import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
-import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
+import { OrganizationBase } from '@/data/organizations/organizations-query'
+import { OrgProject } from '@/data/projects/org-projects-infinite-query'
import { useProfile } from '@/lib/profile'
+import type { Permission } from '@/types'
interface MemberRowProps {
member: OrganizationMember
+ members: OrganizationMember[]
+ roles: OrganizationRolesResponse | undefined
+ isLoadingRoles: boolean
+ orgProjects: OrgProject[]
+ permissions: Permission[] | undefined
+ selectedOrganization: OrganizationBase | undefined
+ organizationMembersDeletionEnabled: boolean
+ onManageAccess: (member: OrganizationMember) => void
}
const MEMBER_ORIGIN_TO_MANAGED_BY = {
vercel: 'vercel-marketplace',
} as const
-export const MemberRow = ({ member }: MemberRowProps) => {
- const { slug } = useParams()
+export const MemberRow = ({
+ member,
+ members,
+ roles,
+ isLoadingRoles,
+ orgProjects,
+ permissions,
+ selectedOrganization,
+ organizationMembersDeletionEnabled,
+ onManageAccess,
+}: MemberRowProps) => {
const { profile } = useProfile()
- const { data: selectedOrganization } = useSelectedOrganizationQuery()
- const { data: roles, isPending: isLoadingRoles } = useOrganizationRolesV2Query({
- slug: selectedOrganization?.slug,
- })
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
- const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
- const orgProjects =
- useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
-
const isInvitedUser = Boolean(member.invited_id)
// Use generic avatar for all team members instead of attempting to fetch from GitHub
@@ -188,7 +196,15 @@ export const MemberRow = ({ member }: MemberRowProps) => {
-
+
)
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx
index 867e13c2e27..47a347a9057 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import { partition } from 'lodash'
import { AlertCircle } from 'lucide-react'
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
import {
Card,
Loading,
@@ -17,9 +17,17 @@ import { Admonition } from 'ui-patterns/Admonition'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { MemberRow } from './MemberRow'
+import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
import { AlertError } from '@/components/ui/AlertError'
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
-import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
+import {
+ useOrganizationMembersQuery,
+ type OrganizationMember,
+} from '@/data/organizations/organization-members-query'
+import { usePermissionsQuery } from '@/data/permissions/permissions-query'
+import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query'
+import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
+import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useProfile } from '@/lib/profile'
export interface MembersViewProps {
@@ -40,12 +48,23 @@ const MembersView = ({ searchString }: MembersViewProps) => {
const {
data: roles,
error: rolesError,
+ isPending: isLoadingRoles,
isSuccess: isSuccessRoles,
isError: isErrorRoles,
} = useOrganizationRolesV2Query({
slug,
})
+ const { data: selectedOrganization } = useSelectedOrganizationQuery()
+ const { data: permissions } = usePermissionsQuery()
+ const organizationMembersDeletionEnabled = useIsFeatureEnabled('organization_members:delete')
+
+ const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
+ const orgProjects = useMemo(
+ () => projectsData?.pages.flatMap((page) => page.projects) ?? [],
+ [projectsData?.pages]
+ )
+
const filteredMembers = useMemo(() => {
return !searchString
? members
@@ -67,6 +86,13 @@ const MembersView = ({ searchString }: MembersViewProps) => {
(m) => m.gotrue_id === profile?.gotrue_id
)
+ const [memberForRoleUpdate, setMemberForRoleUpdate] = useState()
+ const [showRoleUpdatePanel, setShowRoleUpdatePanel] = useState(false)
+ const handleManageAccess = (member: OrganizationMember) => {
+ setMemberForRoleUpdate(member)
+ setShowRoleUpdatePanel(true)
+ }
+
const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
@@ -122,9 +148,35 @@ const MembersView = ({ searchString }: MembersViewProps) => {
,
]
: []),
- ...(!!user ? [] : []),
+ ...(!!user
+ ? [
+ ,
+ ]
+ : []),
...sortedMembers.map((member) => (
-
+
)),
...(searchString.length > 0 && filteredMembers.length === 0
? [
@@ -156,6 +208,14 @@ const MembersView = ({ searchString }: MembersViewProps) => {
)}
+
+ {memberForRoleUpdate && (
+ setShowRoleUpdatePanel(false)}
+ />
+ )}
>
)
}
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx
index afa81dc1426..3d3eebce77c 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx
@@ -1,3 +1,4 @@
+import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
import { Search } from 'lucide-react'
import { useState } from 'react'
@@ -24,6 +25,8 @@ export const TeamSettings = () => {
const { slug } = useParams()
const [searchString, setSearchString] = useState('')
+ const debouncedSearch = useDebounce(searchString, 500)
+
const { data: roles } = useOrganizationRolesV2Query({ slug })
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
@@ -42,7 +45,7 @@ export const TeamSettings = () => {
autoComplete="off"
icon={}
value={searchString}
- onChange={(e: any) => setSearchString(e.target.value)}
+ onChange={(e) => setSearchString(e.target.value)}
name="email"
id="email"
placeholder="Filter members"
@@ -62,7 +65,9 @@ export const TeamSettings = () => {
)}
-
+
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts
index 74b1702e286..343d5285316 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.utils.ts
@@ -1,7 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import type { OrganizationMember } from '@/data/organizations/organization-members-query'
-import { doPermissionsCheck, useGetPermissions } from '@/hooks/misc/useCheckPermissions'
+import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
import type { Permission, Role } from '@/types'
export const useGetRolesManagementPermissions = (
@@ -9,36 +9,30 @@ export const useGetRolesManagementPermissions = (
roles?: Role[],
permissions?: Permission[]
): { rolesAddable: Number[]; rolesRemovable: Number[] } => {
- const { permissions: allPermissions, organizationSlug } = useGetPermissions(
- permissions,
- orgSlug,
- permissions !== undefined && orgSlug !== undefined
- )
-
const rolesAddable: Number[] = []
const rolesRemovable: Number[] = []
if (!roles || !orgSlug) return { rolesAddable, rolesRemovable }
roles.forEach((role: Role) => {
const canAdd = doPermissionsCheck(
- allPermissions,
+ permissions,
PermissionAction.CREATE,
'auth.subject_roles',
{
resource: { role_id: role.id },
},
- organizationSlug
+ orgSlug
)
if (canAdd) rolesAddable.push(role.id)
const canRemove = doPermissionsCheck(
- allPermissions,
+ permissions,
PermissionAction.DELETE,
'auth.subject_roles',
{
resource: { role_id: role.id },
},
- organizationSlug
+ orgSlug
)
if (canRemove) rolesRemovable.push(role.id)
})
diff --git a/apps/studio/hooks/misc/useCheckPermissions.test.ts b/apps/studio/hooks/misc/useCheckPermissions.test.ts
new file mode 100644
index 00000000000..3ac0de113b4
--- /dev/null
+++ b/apps/studio/hooks/misc/useCheckPermissions.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest'
+
+import { doPermissionsCheck } from './useCheckPermissions'
+import type { Permission } from '@/types'
+
+function permission(overrides: Partial): Permission {
+ return {
+ actions: ['read'] as any,
+ condition: null as unknown as Permission['condition'],
+ organization_slug: 'org-slug',
+ resources: ['tables'],
+ restrictive: false,
+ project_refs: null,
+ ...overrides,
+ }
+}
+
+describe('doPermissionsCheck', () => {
+ it('returns false when permissions are missing', () => {
+ expect(doPermissionsCheck(undefined, 'read', 'tables', undefined, 'org-slug')).toBe(false)
+ })
+
+ it('matches a literal action and resource', () => {
+ const permissions = [permission({ actions: ['read'] as any, resources: ['tables'] })]
+ expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(true)
+ expect(doPermissionsCheck(permissions, 'read', 'columns', undefined, 'org-slug')).toBe(false)
+ })
+
+ it('treats every "." in a resource as literal, not "any character"', () => {
+ // Regression for the incomplete-escaping bug: only the first "." used to get escaped,
+ // so a resource with two dots would let any single character stand in for the second one.
+ const permissions = [permission({ resources: ['queue_job.projects.update_jwt'] })]
+ expect(
+ doPermissionsCheck(
+ permissions,
+ 'read',
+ 'queue_job.projects.update_jwt',
+ undefined,
+ 'org-slug'
+ )
+ ).toBe(true)
+ expect(
+ doPermissionsCheck(
+ permissions,
+ 'read',
+ 'queue_jobXprojectsXupdate_jwt',
+ undefined,
+ 'org-slug'
+ )
+ ).toBe(false)
+ })
+
+ it('expands every "%" wildcard in a resource, not just the first one', () => {
+ const permissions = [permission({ resources: ['queue_job.%.%'] })]
+ expect(
+ doPermissionsCheck(permissions, 'read', 'queue_job.restore.prepare', undefined, 'org-slug')
+ ).toBe(true)
+ expect(
+ doPermissionsCheck(
+ permissions,
+ 'read',
+ 'queue_job.walg.prepare_restore',
+ undefined,
+ 'org-slug'
+ )
+ ).toBe(true)
+ })
+
+ it('treats a literal backslash in a resource as a literal character, not a regex escape', () => {
+ const permissions = [permission({ resources: ['a\\d'] })]
+ // If the backslash weren't escaped, "\d" would be interpreted as the regex digit class
+ // and incorrectly match "a1".
+ expect(doPermissionsCheck(permissions, 'read', 'a1', undefined, 'org-slug')).toBe(false)
+ expect(doPermissionsCheck(permissions, 'read', 'a\\d', undefined, 'org-slug')).toBe(true)
+ })
+
+ it('denies when a restrictive permission matches, even if a non-restrictive one also matches', () => {
+ const permissions = [
+ permission({ restrictive: false, resources: ['tables'] }),
+ permission({ restrictive: true, resources: ['tables'] }),
+ ]
+ expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(false)
+ })
+
+ it('only matches permissions for the given organization', () => {
+ const permissions = [permission({ organization_slug: 'other-org' })]
+ expect(doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug')).toBe(false)
+ })
+
+ it('prefers a project-scoped permission over an org-level one when a projectRef is given', () => {
+ const permissions = [
+ permission({ resources: ['tables'], project_refs: [] }),
+ permission({ resources: ['tables'], project_refs: ['project-ref'], restrictive: true }),
+ ]
+ expect(
+ doPermissionsCheck(permissions, 'read', 'tables', undefined, 'org-slug', 'project-ref')
+ ).toBe(false)
+ })
+})
diff --git a/apps/studio/hooks/misc/useCheckPermissions.ts b/apps/studio/hooks/misc/useCheckPermissions.ts
index 68868edae3c..f7ed7aa5936 100644
--- a/apps/studio/hooks/misc/useCheckPermissions.ts
+++ b/apps/studio/hooks/misc/useCheckPermissions.ts
@@ -8,8 +8,18 @@ import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import { IS_PLATFORM } from '@/lib/constants'
import type { Permission } from '@/types'
-const toRegexpString = (actionOrResource: string) =>
- `^${actionOrResource.replace('.', '\\.').replace('%', '.*')}$`
+const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+const regexpCache = new Map()
+const getActionResourceRegexp = (actionOrResource: string) => {
+ let regexp = regexpCache.get(actionOrResource)
+ if (!regexp) {
+ const pattern = actionOrResource.split('%').map(escapeRegExp).join('.*')
+ regexp = new RegExp(`^${pattern}$`)
+ regexpCache.set(actionOrResource, regexp)
+ }
+ return regexp
+}
function doPermissionConditionCheck(permissions: Permission[], data?: object) {
const isRestricted = permissions
@@ -44,8 +54,10 @@ export function doPermissionsCheck(
const projectPermissions = permissions.filter(
(permission) =>
permission.organization_slug === organizationSlug &&
- permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) &&
- permission.resources.some((res) => resource.match(toRegexpString(res))) &&
+ permission.actions.some((act) =>
+ action ? getActionResourceRegexp(act).test(action) : null
+ ) &&
+ permission.resources.some((res) => getActionResourceRegexp(res).test(resource)) &&
permission.project_refs?.includes(projectRef)
)
if (projectPermissions.length > 0) {
@@ -59,8 +71,10 @@ export function doPermissionsCheck(
.filter(
(permission) =>
permission.organization_slug === organizationSlug &&
- permission.actions.some((act) => (action ? action.match(toRegexpString(act)) : null)) &&
- permission.resources.some((res) => resource.match(toRegexpString(res)))
+ permission.actions.some((act) =>
+ action ? getActionResourceRegexp(act).test(action) : null
+ ) &&
+ permission.resources.some((res) => getActionResourceRegexp(res).test(resource))
)
return doPermissionConditionCheck(orgPermissions, { resource_name: resource, ...data })
}