Fix iam pages permissions handling

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-30 18:33:18 +01:00
committed by Bryan Frimin
parent ca533ce7a7
commit e46a11001a
26 changed files with 1848 additions and 1686 deletions

View File

@@ -4,7 +4,6 @@ import { Badge, Button, IconPeopleAdd, Layout, Skeleton } from "@probo/ui";
import { Sidebar } from "./_components/Sidebar";
import { MembershipsDropdown } from "./MembershipsDropdown";
import type { MembershipLayoutQuery } from "/__generated__/iam/MembershipLayoutQuery.graphql";
import { PermissionsProvider } from "/providers/PermissionsProvider";
import { SessionDropdown } from "./_components/SessionDropdown";
import { Suspense } from "react";
import { useTranslate } from "@probo/i18n";
@@ -16,10 +15,10 @@ export const membershipLayoutQuery = graphql`
... on Organization {
...MembershipsDropdown_organizationFragment
...SessionDropdownFragment
...SidebarFragment
}
}
viewer @required(action: THROW) {
...SidebarFragment @arguments(organizationId: $organizationId)
...MembershipsDropdown_viewerFragment
pendingInvitations @required(action: THROW) {
totalCount @required(action: THROW)
@@ -41,39 +40,37 @@ export function MembershipLayout(props: {
);
return (
<PermissionsProvider>
<Layout
header={
<>
<div className="mr-auto">
<MembershipsDropdown
organizationFKey={organization}
viewerFKey={viewer}
/>
{viewer.pendingInvitations.totalCount > 0 && (
<Link to="/" className="relative" title={__("Invitations")}>
<Button variant="tertiary" icon={IconPeopleAdd} />
<Badge
variant="info"
size="sm"
className="absolute -top-1 -right-1 min-w-[20px] h-5 flex items-center justify-center"
>
{viewer.pendingInvitations.totalCount}
</Badge>
</Link>
)}
</div>
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<SessionDropdown fKey={organization} />
</Suspense>
</>
}
sidebar={<Sidebar fKey={viewer} />}
>
<CoreRelayProvider>
<Outlet />
</CoreRelayProvider>
</Layout>
</PermissionsProvider>
<Layout
header={
<>
<div className="mr-auto">
<MembershipsDropdown
organizationFKey={organization}
viewerFKey={viewer}
/>
{viewer.pendingInvitations.totalCount > 0 && (
<Link to="/" className="relative" title={__("Invitations")}>
<Button variant="tertiary" icon={IconPeopleAdd} />
<Badge
variant="info"
size="sm"
className="absolute -top-1 -right-1 min-w-[20px] h-5 flex items-center justify-center"
>
{viewer.pendingInvitations.totalCount}
</Badge>
</Link>
)}
</div>
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<SessionDropdown fKey={organization} />
</Suspense>
</>
}
sidebar={<Sidebar fKey={organization} />}
>
<CoreRelayProvider>
<Outlet />
</CoreRelayProvider>
</Layout>
);
}

View File

@@ -8,8 +8,6 @@ import {
UserDropdownItem,
useToast,
} from "@probo/ui";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useFragment, useMutation } from "react-relay";
@@ -18,6 +16,7 @@ import type { SessionDropdownFragment$key } from "/__generated__/iam/SessionDrop
export const fragment = graphql`
fragment SessionDropdownFragment on Organization {
canDelete: permission(action: "iam:organization:delete")
viewerMembership @required(action: THROW) {
identity @required(action: THROW) {
email
@@ -42,10 +41,10 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { isAuthorized } = use(PermissionsContext);
const { toast } = useToast();
const {
canDelete,
viewerMembership: {
identity: { email },
profile: { fullName },
@@ -73,20 +72,18 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) {
return (
<UserDropdown fullName={fullName} email={email}>
{isAuthorized("Organization", "deleteOrganization") && (
{canDelete && (
<UserDropdownItem
to="/me/api-keys"
icon={IconKey}
label={__("API Keys")}
/>
)}
{isAuthorized("Organization", "listSignableDocuments") && (
<UserDropdownItem
to={`/organizations/${organizationId}/employee`}
icon={IconPageTextLine}
label={__("My Signatures")}
/>
)}
<UserDropdownItem
to={`/organizations/${organizationId}/employee`}
icon={IconPageTextLine}
label={__("My Signatures")}
/>
<UserDropdownItem
to="mailto:support@getprobo.com"
icon={IconCircleQuestionmark}

View File

@@ -1,24 +1,24 @@
import {
IconBank,
IconBook,
IconBox,
IconCalendar1,
IconCircleProgress,
IconClock,
IconCrossLargeX,
IconFire3,
IconGroup1,
IconInboxEmpty,
IconListStack,
IconMedal,
IconPageCheck,
IconPageTextLine,
IconRotateCw,
IconSettingsGear2,
IconShield,
IconStore,
IconTodo,
SidebarItem,
IconBank,
IconBook,
IconBox,
IconCalendar1,
IconCircleProgress,
IconClock,
IconCrossLargeX,
IconFire3,
IconGroup1,
IconInboxEmpty,
IconListStack,
IconMedal,
IconPageCheck,
IconPageTextLine,
IconRotateCw,
IconSettingsGear2,
IconShield,
IconStore,
IconTodo,
SidebarItem,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useOrganizationId } from "/hooks/useOrganizationId";
@@ -27,211 +27,180 @@ import { useFragment } from "react-relay";
import type { SidebarFragment$key } from "/__generated__/iam/SidebarFragment.graphql";
const fragment = graphql`
fragment SidebarFragment on Identity
@argumentDefinitions(organizationId: { type: "ID!" }) {
canListMeetings: permission(
action: "core:meeting:list"
id: $organizationId
)
canListTasks: permission(action: "core:task:list", id: $organizationId)
canListMeasures: permission(
action: "core:measures:list"
id: $organizationId
)
canListRisks: permission(action: "core:risk:list", id: $organizationId)
canListFrameworks: permission(
action: "core:frameworks:list"
id: $organizationId
)
canListPeople: permission(action: "core:people:list", id: $organizationId)
canListVendors: permission(action: "core:vendor:list", id: $organizationId)
canListDocuments: permission(
action: "core:document:list"
id: $organizationId
)
canListAssets: permission(action: "core:asset:list", id: $organizationId)
canListData: permission(action: "core:datum:list", id: $organizationId)
canListAudits: permission(action: "core:audit:list", id: $organizationId)
canListNonconformities: permission(
action: "core:nonconformity:list"
id: $organizationId
)
canListObligations: permission(
action: "core:obligation:list"
id: $organizationId
)
canListContinualImprovements: permission(
action: "core:continual-improvement:list"
id: $organizationId
)
canListProcessingActivities: permission(
action: "core:processing-activity:list"
id: $organizationId
)
canListStatesOfApplicability: permission(
action: "core:state-of-applicability:list"
id: $organizationId
)
canListSnapshots: permission(
action: "core:snapshot:list"
id: $organizationId
)
canGetTrustCenter: permission(
action: "core:trust-center:get"
id: $organizationId
)
canUpdateOrganization: permission(
action: "iam:organization:update"
id: $organizationId
)
}
fragment SidebarFragment on Organization {
canListMeetings: permission(action: "core:meeting:list")
canListTasks: permission(action: "core:task:list")
canListMeasures: permission(action: "core:measures:list")
canListRisks: permission(action: "core:risk:list")
canListFrameworks: permission(action: "core:frameworks:list")
canListPeople: permission(action: "core:people:list")
canListVendors: permission(action: "core:vendor:list")
canListDocuments: permission(action: "core:document:list")
canListAssets: permission(action: "core:asset:list")
canListData: permission(action: "core:datum:list")
canListAudits: permission(action: "core:audit:list")
canListNonconformities: permission(action: "core:nonconformity:list")
canListObligations: permission(action: "core:obligation:list")
canListContinualImprovements: permission(
action: "core:continual-improvement:list"
)
canListProcessingActivities: permission(
action: "core:processing-activity:list"
)
canListStatesOfApplicability: permission(
action: "core:state-of-applicability:list"
)
canListSnapshots: permission(action: "core:snapshot:list")
canGetTrustCenter: permission(action: "core:trust-center:get")
canUpdateOrganization: permission(action: "iam:organization:update")
}
`;
export function Sidebar(props: { fKey: SidebarFragment$key }) {
const { fKey } = props;
const { fKey } = props;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const permissions = useFragment<SidebarFragment$key>(fragment, fKey);
const organization = useFragment<SidebarFragment$key>(fragment, fKey);
const prefix = `/organizations/${organizationId}`;
const prefix = `/organizations/${organizationId}`;
return (
<ul className="space-y-[2px]">
{permissions.canListMeetings && (
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
to={`${prefix}/meetings`}
/>
)}
{permissions.canListTasks && (
<SidebarItem
label={__("Tasks")}
icon={IconInboxEmpty}
to={`${prefix}/tasks`}
/>
)}
{permissions.canListMeasures && (
<SidebarItem
label={__("Measures")}
icon={IconTodo}
to={`${prefix}/measures`}
/>
)}
{permissions.canListRisks && (
<SidebarItem
label={__("Risks")}
icon={IconFire3}
to={`${prefix}/risks`}
/>
)}
{permissions.canListFrameworks && (
<SidebarItem
label={__("Frameworks")}
icon={IconBank}
to={`${prefix}/frameworks`}
/>
)}
{permissions.canListPeople && (
<SidebarItem
label={__("People")}
icon={IconGroup1}
to={`${prefix}/people`}
/>
)}
{permissions.canListVendors && (
<SidebarItem
label={__("Vendors")}
icon={IconStore}
to={`${prefix}/vendors`}
/>
)}
{permissions.canListDocuments && (
<SidebarItem
label={__("Documents")}
icon={IconPageTextLine}
to={`${prefix}/documents`}
/>
)}
{permissions.canListAssets && (
<SidebarItem
label={__("Assets")}
icon={IconBox}
to={`${prefix}/assets`}
/>
)}
{permissions.canListData && (
<SidebarItem
label={__("Data")}
icon={IconListStack}
to={`${prefix}/data`}
/>
)}
{permissions.canListAudits && (
<SidebarItem
label={__("Audits")}
icon={IconMedal}
to={`${prefix}/audits`}
/>
)}
{permissions.canListNonconformities && (
<SidebarItem
label={__("Nonconformities")}
icon={IconCrossLargeX}
to={`${prefix}/nonconformities`}
/>
)}
{permissions.canListObligations && (
<SidebarItem
label={__("Obligations")}
icon={IconBook}
to={`${prefix}/obligations`}
/>
)}
{permissions.canListContinualImprovements && (
<SidebarItem
label={__("Continual Improvements")}
icon={IconRotateCw}
to={`${prefix}/continual-improvements`}
/>
)}
{permissions.canListProcessingActivities && (
<SidebarItem
label={__("Processing Activities")}
icon={IconCircleProgress}
to={`${prefix}/processing-activities`}
/>
)}
{permissions.canListStatesOfApplicability && (
<SidebarItem
label={__("States of Applicability")}
icon={IconPageCheck}
to={`${prefix}/states-of-applicability`}
/>
)}
{permissions.canListSnapshots && (
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
)}
{permissions.canGetTrustCenter && (
<SidebarItem
label={__("Trust Center")}
icon={IconShield}
to={`${prefix}/trust-center`}
/>
)}
{permissions.canUpdateOrganization && (
<SidebarItem
label={__("Settings")}
icon={IconSettingsGear2}
to={`${prefix}/settings`}
/>
)}
</ul>
);
return (
<ul className="space-y-[2px]">
{organization.canListMeetings && (
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
to={`${prefix}/meetings`}
/>
)}
{organization.canListTasks && (
<SidebarItem
label={__("Tasks")}
icon={IconInboxEmpty}
to={`${prefix}/tasks`}
/>
)}
{organization.canListMeasures && (
<SidebarItem
label={__("Measures")}
icon={IconTodo}
to={`${prefix}/measures`}
/>
)}
{organization.canListRisks && (
<SidebarItem
label={__("Risks")}
icon={IconFire3}
to={`${prefix}/risks`}
/>
)}
{organization.canListFrameworks && (
<SidebarItem
label={__("Frameworks")}
icon={IconBank}
to={`${prefix}/frameworks`}
/>
)}
{organization.canListPeople && (
<SidebarItem
label={__("People")}
icon={IconGroup1}
to={`${prefix}/people`}
/>
)}
{organization.canListVendors && (
<SidebarItem
label={__("Vendors")}
icon={IconStore}
to={`${prefix}/vendors`}
/>
)}
{organization.canListDocuments && (
<SidebarItem
label={__("Documents")}
icon={IconPageTextLine}
to={`${prefix}/documents`}
/>
)}
{organization.canListAssets && (
<SidebarItem
label={__("Assets")}
icon={IconBox}
to={`${prefix}/assets`}
/>
)}
{organization.canListData && (
<SidebarItem
label={__("Data")}
icon={IconListStack}
to={`${prefix}/data`}
/>
)}
{organization.canListAudits && (
<SidebarItem
label={__("Audits")}
icon={IconMedal}
to={`${prefix}/audits`}
/>
)}
{organization.canListNonconformities && (
<SidebarItem
label={__("Nonconformities")}
icon={IconCrossLargeX}
to={`${prefix}/nonconformities`}
/>
)}
{organization.canListObligations && (
<SidebarItem
label={__("Obligations")}
icon={IconBook}
to={`${prefix}/obligations`}
/>
)}
{organization.canListContinualImprovements && (
<SidebarItem
label={__("Continual Improvements")}
icon={IconRotateCw}
to={`${prefix}/continual-improvements`}
/>
)}
{organization.canListProcessingActivities && (
<SidebarItem
label={__("Processing Activities")}
icon={IconCircleProgress}
to={`${prefix}/processing-activities`}
/>
)}
{organization.canListStatesOfApplicability && (
<SidebarItem
label={__("States of Applicability")}
icon={IconPageCheck}
to={`${prefix}/states-of-applicability`}
/>
)}
{organization.canListSnapshots && (
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
)}
{organization.canGetTrustCenter && (
<SidebarItem
label={__("Trust Center")}
icon={IconShield}
to={`${prefix}/trust-center`}
/>
)}
{organization.canUpdateOrganization && (
<SidebarItem
label={__("Settings")}
icon={IconSettingsGear2}
to={`${prefix}/settings`}
/>
)}
</ul>
);
}

View File

@@ -10,16 +10,6 @@ import { InviteUserDialog } from "./_components/InviteUserDialog";
export const membersPageQuery = graphql`
query MembersPageQuery($organizationId: ID!) {
viewer @required(action: THROW) {
canInviteUser: permission(
action: "iam:membership:create"
id: $organizationId
)
...InvitationListItem_permissionsFragment
@arguments(organizationId: $organizationId)
...MemberListItem_permissionsFragment
@arguments(organizationId: $organizationId)
}
organization: node(id: $organizationId) @required(action: THROW) {
__typename
... on Organization {
@@ -36,6 +26,7 @@ export const membersPageQuery = graphql`
@required(action: THROW) {
totalCount
}
canInviteUser: permission(action: "iam:invitation:create")
}
}
}
@@ -52,7 +43,7 @@ export function MembersPage(props: {
"memberships",
);
const { organization, viewer } = usePreloadedQuery<MembersPageQuery>(
const { organization } = usePreloadedQuery<MembersPageQuery>(
membersPageQuery,
queryRef,
);
@@ -64,7 +55,7 @@ export function MembersPage(props: {
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-base font-medium">{__("Workspace members")}</h2>
{viewer.canInviteUser && (
{organization.canInviteUser && (
<InviteUserDialog viewerMembershipFKey={organization}>
<Button variant="secondary">{__("Invite member")}</Button>
</InviteUserDialog>
@@ -94,12 +85,10 @@ export function MembersPage(props: {
<Card>
<div className="px-6 pb-6 pt-6">
{activeTab === "memberships" && (
<MemberList fKey={organization} permissionsFKey={viewer} />
)}
{activeTab === "memberships" && <MemberList fKey={organization} />}
{activeTab === "invitations" && (
<InvitationList fKey={organization} permissionsFKey={viewer} />
<InvitationList fKey={organization} />
)}
</div>
</Card>

View File

@@ -4,7 +4,6 @@ import { useTranslate } from "@probo/i18n";
import { graphql, usePaginationFragment } from "react-relay";
import { InvitationListItem } from "./InvitationListItem";
import type { InvitationListFragment$key } from "/__generated__/iam/InvitationListFragment.graphql";
import type { InvitationListItem_permissionsFragment$key } from "/__generated__/iam/InvitationListItem_permissionsFragment.graphql";
import type { InvitationListFragment_RefetchQuery } from "/__generated__/iam/InvitationListFragment_RefetchQuery.graphql";
const fragment = graphql`
@@ -41,11 +40,8 @@ const fragment = graphql`
}
`;
export function InvitationList(props: {
fKey: InvitationListFragment$key;
permissionsFKey: InvitationListItem_permissionsFragment$key;
}) {
const { fKey, permissionsFKey } = props;
export function InvitationList(props: { fKey: InvitationListFragment$key }) {
const { fKey } = props;
const { __ } = useTranslate();
@@ -106,7 +102,6 @@ export function InvitationList(props: {
key={invitation.id}
fKey={invitation}
onRefetch={refetchInvitations}
permissionsFKey={permissionsFKey}
/>
),
)

View File

@@ -15,7 +15,6 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { InvitationListItemFragment$key } from "/__generated__/iam/InvitationListItemFragment.graphql";
import type { InvitationListItem_permissionsFragment$key } from "/__generated__/iam/InvitationListItem_permissionsFragment.graphql";
const fragment = graphql`
fragment InvitationListItemFragment on Invitation {
@@ -27,16 +26,7 @@ const fragment = graphql`
createdAt
expiresAt
acceptedAt
}
`;
const permissionsFragment = graphql`
fragment InvitationListItem_permissionsFragment on Identity
@argumentDefinitions(organizationId: { type: "ID!" }) {
canDeleteInvitation: permission(
action: "iam:invitation:delete"
id: $organizationId
)
canDelete: permission(action: "iam:invitation:delete")
}
`;
@@ -54,10 +44,9 @@ const deleteInvitationMutation = graphql`
export function InvitationListItem(props: {
connectionId: string;
fKey: InvitationListItemFragment$key;
permissionsFKey: InvitationListItem_permissionsFragment$key;
onRefetch: () => void;
}) {
const { connectionId, fKey, permissionsFKey } = props;
const { connectionId, fKey } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -67,10 +56,6 @@ export function InvitationListItem(props: {
fragment,
fKey,
);
const permissions = useFragment<InvitationListItem_permissionsFragment$key>(
permissionsFragment,
permissionsFKey,
);
const [deleteInvitation, isDeleting] = useMutationWithToasts(
deleteInvitationMutation,
@@ -134,7 +119,7 @@ export function InvitationListItem(props: {
{isDeleting ? (
<Spinner size={16} />
) : (
permissions.canDeleteInvitation && (
invitation.canDelete && (
<Button
variant="danger"
onClick={handleDelete}

View File

@@ -5,7 +5,6 @@ import { SortableTable, SortableTh } from "/components/SortableTable";
import type { MemberListFragment_RefetchQuery } from "/__generated__/iam/MemberListFragment_RefetchQuery.graphql";
import { MemberListItem } from "./MemberListItem";
import type { MemberListFragment$key } from "/__generated__/iam/MemberListFragment.graphql";
import type { MemberListItem_permissionsFragment$key } from "/__generated__/iam/MemberListItem_permissionsFragment.graphql";
const fragment = graphql`
fragment MemberListFragment on Organization
@@ -40,11 +39,8 @@ const fragment = graphql`
}
`;
export function MemberList(props: {
fKey: MemberListFragment$key;
permissionsFKey: MemberListItem_permissionsFragment$key;
}) {
const { fKey, permissionsFKey } = props;
export function MemberList(props: { fKey: MemberListFragment$key }) {
const { fKey } = props;
const { __ } = useTranslate();
@@ -98,7 +94,6 @@ export function MemberList(props: {
key={membership.id}
fKey={membership}
onRefetch={refetchMemberships}
permissionsFKey={permissionsFKey}
viewerFKey={membersPagination.data}
/>
))

View File

@@ -14,7 +14,6 @@ import { useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { MemberListItemFragment$key } from "/__generated__/iam/MemberListItemFragment.graphql";
import type { MemberListItem_permissionsFragment$key } from "/__generated__/iam/MemberListItem_permissionsFragment.graphql";
import type { MemberListItem_currentRoleFragment$key } from "/__generated__/iam/MemberListItem_currentRoleFragment.graphql";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useOrganizationId } from "/hooks/useOrganizationId";
@@ -32,6 +31,8 @@ const fragment = graphql`
email
}
createdAt
canUpdate: permission(action: "iam:membership:update")
canDelete: permission(action: "iam:membership:delete")
}
`;
@@ -43,20 +44,6 @@ const currentRoleFragment = graphql`
}
`;
const permissionsFragment = graphql`
fragment MemberListItem_permissionsFragment on Identity
@argumentDefinitions(organizationId: { type: "ID!" }) {
canUpdateMembership: permission(
action: "iam:membership:update"
id: $organizationId
)
canDeleteMembership: permission(
action: "iam:membership:delete"
id: $organizationId
)
}
`;
const removeMemberMutation = graphql`
mutation MemberListItem_removeMutation(
$input: RemoveMemberInput!
@@ -71,11 +58,10 @@ const removeMemberMutation = graphql`
export function MemberListItem(props: {
connectionId: string;
fKey: MemberListItemFragment$key;
permissionsFKey: MemberListItem_permissionsFragment$key;
viewerFKey: MemberListItem_currentRoleFragment$key;
onRefetch: () => void;
}) {
const { fKey, connectionId, permissionsFKey, viewerFKey } = props;
const { fKey, connectionId, viewerFKey } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -88,10 +74,6 @@ export function MemberListItem(props: {
currentRoleFragment,
viewerFKey,
);
const permissions = useFragment<MemberListItem_permissionsFragment$key>(
permissionsFragment,
permissionsFKey,
);
// Only OWNER can edit OWNER members
const canEditThisRole =
@@ -151,7 +133,7 @@ export function MemberListItem(props: {
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
{permissions.canUpdateMembership && canEditThisRole && (
{membership.canUpdate && canEditThisRole && (
<Button
variant="secondary"
onClick={() => setDialogOpen(true)}
@@ -163,7 +145,7 @@ export function MemberListItem(props: {
{isRemoving ? (
<Spinner size={16} />
) : (
permissions.canDeleteMembership &&
membership.canDelete &&
canEditThisRole && (
<Button
variant="danger"