From e451d5252adaf0b2c4b4f990ec9bc628a6343a7f Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 17 Jan 2025 11:18:39 +0100 Subject: [PATCH] wip2 --- .../providers/organization-members.ts | 8 +- .../components/organization/members/list.tsx | 121 +++ .../organization/members/resource-picker.tsx | 838 ++++++++++++++++++ 3 files changed, 963 insertions(+), 4 deletions(-) create mode 100644 packages/web/app/src/components/organization/members/resource-picker.tsx diff --git a/packages/services/api/src/modules/organization/providers/organization-members.ts b/packages/services/api/src/modules/organization/providers/organization-members.ts index cf1709529cd..11f93f328c6 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -327,17 +327,17 @@ export class OrganizationMembers { }, }; }) - .filter(isNone), + .filter(isSome), }, }; }) - .filter(isNone), + .filter(isSome), }; } } -function isNone(input: T | null): input is Exclude { - return input == null; +function isSome(input: T | null): input is Exclude { + return input != null; } const organizationMemberFields = (prefix = sql`"organization_member"`) => sql` diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index d11fd951649..c06a94e9d0c 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -14,12 +14,14 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Link } from '@/components/ui/link'; import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useToast } from '@/components/ui/use-toast'; @@ -27,6 +29,7 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { AuthProvider } from '@/gql/graphql'; import { RoleSelector } from './common'; import { MemberInvitationButton } from './invitations'; +import { ResourcePicker, ResourceSelection } from './resource-picker'; const OrganizationMemberRoleSwitcher_AssignRoleMutation = graphql(` mutation OrganizationMemberRoleSwitcher_AssignRoleMutation($input: AssignMemberRoleInput!) { @@ -120,6 +123,9 @@ function OrganizationMemberRoleSwitcher(props: { organizationSlug: organization.slug, roleId: role.id, userId: member.user.id, + // resources: { + // allProjects: true, + // }, }, }); @@ -226,6 +232,7 @@ const OrganizationMemberRow_MemberFragment = graphql(` isOwner viewerCanRemove ...OrganizationMemberRoleSwitcher_MemberFragment + ...AssignedResources_MemberFragment } `); @@ -338,6 +345,13 @@ function OrganizationMemberRow(props: { /> )} + + {member.isOwner ? ( + 'all resources' + ) : ( + + )} + {member.viewerCanRemove && ( @@ -358,6 +372,106 @@ function OrganizationMemberRow(props: { ); } +const AssignedResources_OrganizationFragment = graphql(` + fragment AssignedResources_OrganizationFragment on Organization { + id + ...ResourcePicker_OrganizationFragment + } +`); + +const AssignedResources_MemberFragment = graphql(` + fragment AssignedResources_MemberFragment on Member { + id + resourceAssignment { + allProjects + projects { + project { + id + slug + } + targets { + allTargets + targets { + target { + id + slug + } + services { + allServices + services + } + appDeployments { + allAppDeployments + appDeployments + } + } + } + } + } + } +`); + +function AssignedResources(props: { + member: FragmentType; + organization: FragmentType; +}) { + const member = useFragment(AssignedResources_MemberFragment, props.member); + const organization = useFragment(AssignedResources_OrganizationFragment, props.organization); + + const [isOpen, setIsOpen] = useState(false); + const initialSelection = useMemo(() => { + return { + projects: + member.resourceAssignment.allProjects === true + ? '*' + : (member.resourceAssignment.projects ?? []).map(record => ({ + id: record.project.id, + slug: record.project.slug, + targets: + record.targets.allTargets === true + ? '*' + : (record.targets.targets ?? []).map(record => ({ + id: record.target.id, + slug: record.target.slug, + appDeployments: + record.appDeployments.allAppDeployments === true + ? '*' + : (record.appDeployments.appDeployments ?? []), + services: + record.services.allServices === true + ? '*' + : (record.services.services ?? []), + })), + })), + }; + }, [member]); + + return ( + <> + {member.resourceAssignment.allProjects ? ( + 'all resources' + ) : member.resourceAssignment.projects?.length ? ( + <> + {member.resourceAssignment.projects.length} project + {member.resourceAssignment.projects.length === 1 ? '' : 's'} + + ) : ( + 'none' + )}{' '} + setIsOpen(isOpen)}> + + manage + + + {isOpen && ( + + )} + + + + ); +} + const OrganizationMembers_OrganizationFragment = graphql(` fragment OrganizationMembers_OrganizationFragment on Organization { id @@ -382,6 +496,7 @@ const OrganizationMembers_OrganizationFragment = graphql(` viewerCanManageInvitations ...OrganizationMemberRoleSwitcher_OrganizationFragment ...MemberInvitationForm_OrganizationFragment + ...AssignedResources_OrganizationFragment } `); @@ -479,6 +594,12 @@ export function OrganizationMembers(props: { ) : null} + updateSorting('role')} + > + Projects + diff --git a/packages/web/app/src/components/organization/members/resource-picker.tsx b/packages/web/app/src/components/organization/members/resource-picker.tsx new file mode 100644 index 00000000000..2279fdc1cad --- /dev/null +++ b/packages/web/app/src/components/organization/members/resource-picker.tsx @@ -0,0 +1,838 @@ +import { useMemo, useState } from 'react'; +import { produce } from 'immer'; +import { ChevronRightIcon } from 'lucide-react'; +import { useQuery } from 'urql'; +import { Button } from '@/components/ui/button'; +import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { ProjectType } from '@/gql/graphql'; +import { cn } from '@/lib/utils'; + +export type ResourceSelection = { + projects: + | '*' + | Array<{ + id: string; + slug: string; + targets: + | '*' + | Array<{ + id: string; + slug: string; + appDeployments: '*' | Array; + services: '*' | Array; + }>; + }>; +}; + +const ResourcePicker_OrganizationFragment = graphql(` + fragment ResourcePicker_OrganizationFragment on Organization { + id + slug + projects { + nodes { + id + slug + type + } + } + } +`); + +const ResourcePicker_OrganizationProjectTargetsQuery = graphql(` + query ResourcePicker_OrganizationProjectTargetsQuery( + $organizationSlug: String! + $projectSlug: String! + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + project: projectBySlug(projectSlug: $projectSlug) { + id + type + targets { + nodes { + id + slug + } + } + } + } + } +`); + +const ResourcePicker_OrganizationProjectTargetQuery = graphql(` + query ResourcePicker_OrganizationProjectTargetQuery( + $organizationSlug: String! + $projectSlug: String! + $targetSlug: String! + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + project: projectBySlug(projectSlug: $projectSlug) { + id + type + targets { + nodes { + id + slug + } + } + target: targetBySlug(targetSlug: $targetSlug) { + id + latestValidSchemaVersion { + id + schemas { + nodes { + ... on CompositeSchema { + id + service + } + } + } + } + } + } + } + } +`); + +export function ResourcePicker(props: { + initialSelection?: ResourceSelection; + organization: FragmentType; +}) { + const organization = useFragment(ResourcePicker_OrganizationFragment, props.organization); + const [breadcrumb, setBreadcrumb] = useState( + null as + | null + | { projectId: string; targetId?: undefined } + | { projectId: string; targetId: string; mode: 'service' | 'appDeployment' }, + ); + const [selection, setSelection] = useState( + props.initialSelection ?? { projects: '*' }, + ); + + const projectState = useMemo(() => { + if (selection.projects === '*') { + return null; + } + + type SelectedItem = { + project: (typeof organization.projects.nodes)[number]; + projectSelection: (typeof selection.projects)[number]; + }; + + type NotSelectedItem = (typeof organization.projects.nodes)[number]; + + const selectedProjects: Array = []; + const notSelectedProjects: Array = []; + + let activeProject: null | { + project: (typeof organization.projects.nodes)[number]; + projectSelection: (typeof selection.projects)[number]; + } = null; + + for (const project of organization.projects.nodes) { + const projectSelection = selection.projects.find(item => item.id === project.id); + + if (projectSelection) { + selectedProjects.push({ project, projectSelection }); + + if (project.id === projectSelection.id) { + activeProject = { project, projectSelection }; + } + + continue; + } + + notSelectedProjects.push(project); + } + + return { + selected: selectedProjects, + notSelected: notSelectedProjects, + activeProject, + addSelection(item: (typeof organization.projects.nodes)[number]) { + setSelection(state => + produce(state, state => { + if (state.projects === '*') { + return; + } + + state.projects.push({ + id: item.id, + slug: item.slug, + targets: '*', + }); + }), + ); + }, + }; + }, [organization.projects.nodes, selection]); + + const [organizationProjectTargets] = useQuery({ + query: ResourcePicker_OrganizationProjectTargetsQuery, + pause: !projectState?.activeProject, + variables: { + organizationSlug: organization.slug, + projectSlug: projectState?.activeProject?.project.slug ?? '', + }, + }); + + const targetState = useMemo(() => { + if ( + !organizationProjectTargets?.data?.organization?.project?.targets?.nodes || + !projectState?.activeProject + ) { + return null; + } + + const projectId = projectState.activeProject.project.id; + + if (projectState.activeProject.projectSelection.targets === '*') { + return { + selection: '*', + setGranular() { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + + const project = state.projects.find(project => project.id === projectId); + if (!project) return; + + project.targets = []; + }), + ); + }, + } as const; + } + + type SelectedItem = { + target: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number]; + targetSelection: (typeof projectState.activeProject.projectSelection.targets)[number]; + }; + + type NotSelectedItem = + (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number]; + + const selected: Array = []; + const notSelected: Array = []; + + let activeTarget: null | { + targetSelection: (typeof projectState.activeProject.projectSelection.targets)[number]; + target: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number]; + } = null; + + for (const target of organizationProjectTargets.data.organization.project.targets.nodes) { + const targetSelection = projectState.activeProject.projectSelection.targets.find( + item => item.id === target.id, + ); + + if (targetSelection) { + selected.push({ target, targetSelection }); + + if (breadcrumb?.targetId === target.id) { + activeTarget = { + targetSelection, + target, + }; + } + continue; + } + + notSelected.push(target); + } + + return { + selection: { + selected, + notSelected, + }, + activeTarget, + addSelection( + item: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number], + ) { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project) return; + if (project.targets === '*') return; + project.targets.push({ + id: item.id, + slug: item.slug, + appDeployments: '*', + services: '*', + }); + }), + ); + }, + removeSelection( + item: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number], + ) { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project) return; + if (project.targets === '*') return; + project.targets = project.targets.filter(target => target.id === item.id); + }), + ); + }, + setAll() { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project) return; + project.targets = '*'; + }), + ); + setBreadcrumb({ projectId }); + }, + }; + }, [ + projectState?.activeProject, + organizationProjectTargets?.data?.organization?.project?.targets?.nodes, + breadcrumb?.targetId, + ]); + + const [organizationProjectTarget] = useQuery({ + query: ResourcePicker_OrganizationProjectTargetQuery, + pause: !targetState?.activeTarget || !projectState?.activeProject, + variables: { + organizationSlug: organization.slug, + projectSlug: projectState?.activeProject?.project.slug ?? '', + targetSlug: targetState?.activeTarget?.target?.slug ?? '', + }, + }); + + const serviceState = useMemo(() => { + if ( + !projectState?.activeProject || + !targetState?.activeTarget || + !breadcrumb?.targetId || + breadcrumb.mode !== 'service' || + !organizationProjectTarget.data?.organization?.project?.type || + // we can not assign services for a monolithic schema + organizationProjectTarget.data.organization.project.type === ProjectType.Single + ) { + return null; + } + + const projectId = projectState.activeProject.projectSelection.id; + const targetId = targetState.activeTarget.targetSelection.id; + + if (targetState.activeTarget.targetSelection.services === '*') { + return { + selection: '*' as const, + setGranular() { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project || project.targets === '*') return; + const target = project.targets.find(target => target.id === targetId); + if (!target) return; + target.services = []; + }), + ); + }, + }; + } + + const selectedServices: Array = [...targetState.activeTarget.targetSelection.services]; + const notSelectedServices: Array = []; + + if ( + organizationProjectTarget.data.organization.project.target?.latestValidSchemaVersion?.schemas + ) { + for (const schema of organizationProjectTarget.data.organization.project.target + .latestValidSchemaVersion.schemas.nodes) { + if ( + schema.__typename === 'CompositeSchema' && + schema.service && + !selectedServices.find(serviceName => serviceName === schema.service) + ) { + notSelectedServices.push(schema.service); + } + } + } + + return { + selection: { + selected: selectedServices, + notSelected: notSelectedServices, + }, + setAll() { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project || project.targets === '*') return; + const target = project.targets.find(target => target.id === targetId); + if (!target) return; + target.services = '*'; + }), + ); + }, + addService(serviceName: string) { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project || project.targets === '*') return; + const target = project.targets.find(target => target.id === targetId); + if ( + !target || + target.services === '*' || + target.services.find(service => service === serviceName) + ) { + return; + } + + target.services.push(serviceName); + }), + ); + }, + }; + }, [targetState?.activeTarget, breadcrumb, projectState?.activeProject]); + + const appDeploymentState = useMemo(() => { + if ( + !projectState?.activeProject || + !targetState?.activeTarget || + !breadcrumb?.targetId || + breadcrumb.mode !== 'appDeployment' + ) { + return null; + } + + const projectId = projectState.activeProject.projectSelection.id; + const targetId = targetState.activeTarget.targetSelection.id; + + if (targetState.activeTarget.targetSelection.appDeployments === '*') { + return { + selection: '*' as const, + setGranular() { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project || project.targets === '*') return; + const target = project.targets.find(target => target.id === targetId); + if (!target) return; + target.appDeployments = []; + }), + ); + }, + }; + } + + const selected: Array = [...targetState.activeTarget.targetSelection.appDeployments]; + const notSelected: Array = []; + + return { + selection: { + selected, + notSelected, + }, + setAll() { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project || project.targets === '*') return; + const target = project.targets.find(target => target.id === targetId); + if (!target) return; + target.appDeployments = '*'; + }), + ); + }, + addAppDeployment(appDeploymentName: string) { + setSelection(state => + produce(state, state => { + if (state.projects === '*') return; + const project = state.projects.find(project => project.id === projectId); + if (!project || project.targets === '*') return; + const target = project.targets.find(target => target.id === targetId); + if ( + !target || + target.appDeployments === '*' || + target.appDeployments.find(appDeployment => appDeployment === appDeploymentName) + ) { + return; + } + + target.appDeployments.push(appDeploymentName); + }), + ); + }, + }; + }, [targetState?.activeTarget, breadcrumb, projectState?.activeProject]); + + return ( + <> + + Select access + + + + { + setSelection({ projects: '*' }); + setBreadcrumb(null); + }} + > + Full Access + + { + setSelection({ projects: [] }); + }} + > + Granular Access + + + +

+ This mode grants permissions specified by the user role on all resources within the + organization. +

+
+ + {projectState && ( + <> +

+ The permissions granted by the assigned user role are applied for the specified + resources. +

+
+
Projects
+ {targetState ? ( +
Targets
+ ) : ( +
+ )} + {serviceState || appDeploymentState ? ( +
+ {' '} + /{' '} + +
+ ) : ( +
+ )} +
+
+
+
access granted
+ {projectState.selected.length ? ( + projectState.selected.map(selection => ( + { + setBreadcrumb({ projectId: selection.project.id }); + }} + /> + )) + ) : ( +
None
+ )} +
Unselected
+ {projectState.notSelected.length ? ( + projectState.notSelected.map(project => ( + + setSelection(state => + produce(state, state => { + if (state.projects === '*') { + return; + } + + state.projects.push({ + id: project.id, + slug: project.slug, + targets: '*', + }); + }), + ) + } + /> + )) + ) : ( +
None
+ )} +
+ {targetState ? ( +
+ {targetState.selection === '*' ? ( +
+ Access to all targets of project granted. +
+ ) : ( + <> +
+ access granted +
+ {targetState.selection.selected.length ? ( + targetState.selection.selected.map(selection => ( + { + const project = projectState.activeProject?.project; + if (!project) return; + setBreadcrumb({ + projectId: project.id, + targetId: selection.target.id, + mode: + project.type === ProjectType.Single + ? 'appDeployment' + : 'service', + }); + }} + /> + )) + ) : ( +
None
+ )} +
+ Unselected +
+ {targetState.selection.notSelected.length ? ( + targetState.selection.notSelected.map(target => ( + targetState.addSelection(target)} + /> + )) + ) : ( +
None
+ )} + + )} + +
+ Mode{' '} + + +
+
+ ) : ( +
+ )} + {serviceState ? ( +
+ {serviceState.selection === '*' ? ( +
+ Access to all services in target granted. +
+ ) : ( + <> +
+ access granted +
+ {serviceState.selection.selected.length ? ( + serviceState.selection.selected.map(serviceName => ( + + )) + ) : ( +
None
+ )} +
+ Unselected +
+ {serviceState.selection.notSelected.map(serviceName => ( + serviceState.selectService(service.id)} + /> + ))} + + + )} +
+ Mode{' '} + + +
+
+ ) : appDeploymentState ? ( +
+ {appDeploymentState.selection === '*' ? ( +
+ Access to all app deployments in target granted. +
+ ) : ( + <> +
+ access granted +
+ {appDeploymentState.selection.selected.length ? ( + appDeploymentState.selection.selected.map(appDeploymentName => ( + + )) + ) : ( +
None
+ )} +
+ Unselected +
+ {appDeploymentState.selection.notSelected.map(appDeploymentName => ( + + ))} +
{ + ev.preventDefault(); + const input: HTMLInputElement = ev.currentTarget.appDeploymentName; + const appDeploymentName = input.value.trim().toLowerCase(); + if (!appDeploymentName) { + return; + } + + input.value = ''; + appDeploymentState.addAppDeployment(appDeploymentName); + }} + > + +
+ + )} +
+ Mode{' '} + + +
+
+ ) : ( +
+ )} +
+ + )} + + + + + + + ); +} + +function Row(props: { title: string; isActive?: boolean; onClick?: () => void }) { + return ( + + ); +}