Skip to content

Commit

Permalink
feat(console): assign permissions for org roles
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun committed Apr 10, 2024
1 parent ec31b18 commit a50f8f0
Show file tree
Hide file tree
Showing 23 changed files with 400 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import ConfirmModal from '@/ds-components/ConfirmModal';
import DataTransferBox from '@/ds-components/DataTransferBox';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import TabWrapper from '@/ds-components/TabWrapper';

import { PermissionType } from './types';
import useOrganizationRolePermissionsAssignment from './use-organization-role-permissions-assignment';

const permissionTabs = {
[PermissionType.Organization]: {
title: 'organization_role_details.permissions.organization_permissions',
key: PermissionType.Organization,
},
[PermissionType.Api]: {
title: 'organization_role_details.permissions.api_permissions',
key: PermissionType.Api,
},
} satisfies {
[key in PermissionType]: {
title: AdminConsoleKey;
key: key;
};
};

type Props = {
organizationRoleId: string;
isOpen: boolean;
onClose: () => void;
};

function OrganizationRolePermissionsAssignmentModal({
organizationRoleId,
isOpen,
onClose,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

const {
activeTab,
setActiveTab,
onSubmit,
organizationScopesAssignment,
resourceScopesAssignment,
clearSelectedData,
isLoading,
} = useOrganizationRolePermissionsAssignment(organizationRoleId);

const onCloseHandler = useCallback(() => {
onClose();
clearSelectedData();
setActiveTab(PermissionType.Organization);
}, [clearSelectedData, onClose, setActiveTab]);

const onSubmitHandler = useCallback(async () => {
await onSubmit();
onCloseHandler();
}, [onCloseHandler, onSubmit]);

const tabs = useMemo(
() =>
Object.values(permissionTabs).map(({ title, key }) => {
const selectedDataCount =
key === PermissionType.Organization
? organizationScopesAssignment.selectedData.length
: resourceScopesAssignment.selectedData.length;

return (
<TabNavItem
key={key}
isActive={key === activeTab}
onClick={() => {
setActiveTab(key);
}}
>
{`${t(title)}${selectedDataCount ? ` (${selectedDataCount})` : ''}`}
</TabNavItem>
);
}),
[
activeTab,
organizationScopesAssignment.selectedData.length,
resourceScopesAssignment.selectedData.length,
setActiveTab,
t,
]
);

return (
<ConfirmModal
isOpen={isOpen}
isLoading={isLoading}
title="organization_role_details.permissions.assign_permissions"
subtitle="organization_role_details.permissions.assign_description"
confirmButtonType="primary"
confirmButtonText="general.save"
cancelButtonText="general.discard"
size="large"
onCancel={onCloseHandler}
onConfirm={onSubmitHandler}
>
<TabNav>{tabs}</TabNav>
<TabWrapper
key={PermissionType.Organization}
isActive={PermissionType.Organization === activeTab}
>
<DataTransferBox
title="organization_role_details.permissions.assign_organization_permissions"
{...organizationScopesAssignment}
/>
</TabWrapper>
<TabWrapper key={PermissionType.Api} isActive={PermissionType.Api === activeTab}>
<DataTransferBox
title="organization_role_details.permissions.assign_api_permissions"
{...resourceScopesAssignment}
/>
</TabWrapper>
</ConfirmModal>
);
}

export default OrganizationRolePermissionsAssignmentModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum PermissionType {
Organization = 'Organization',
Api = 'Api',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { cond } from '@silverhand/essentials';
import { useCallback, useState } from 'react';

import useApi from '@/hooks/use-api';
import useOrganizationRoleScopes from '@/pages/OrganizationRoleDetails/Permissions/use-organization-role-scopes';

import { PermissionType } from './types';
import useOrganizationScopesAssignment from './use-organization-scopes-assignment';
import useResourceScopesAssignment from './use-resource-scopes-assignment';

function useOrganizationRolePermissionsAssignment(organizationRoleId: string) {
const organizationRolePath = `api/organization-roles/${organizationRoleId}`;
const [activeTab, setActiveTab] = useState<PermissionType>(PermissionType.Organization);
const [isLoading, setIsLoading] = useState(false);
const api = useApi();

const { organizationScopes, resourceScopes, mutate } =
useOrganizationRoleScopes(organizationRoleId);

const organizationScopesAssignment = useOrganizationScopesAssignment(organizationScopes);
const resourceScopesAssignment = useResourceScopesAssignment(resourceScopes);

const clearSelectedData = useCallback(() => {
organizationScopesAssignment.setSelectedData([]);
resourceScopesAssignment.setSelectedData([]);
}, [organizationScopesAssignment, resourceScopesAssignment]);

const onSubmit = useCallback(async () => {
setIsLoading(true);
const newOrganizationScopes = organizationScopesAssignment.selectedData.map(({ id }) => id);
const newResourceScopes = resourceScopesAssignment.selectedData.map(({ id }) => id);

await Promise.all(
[
cond(
newOrganizationScopes.length > 0 &&
api.post(`${organizationRolePath}/scopes`, {
json: { organizationScopeIds: newOrganizationScopes },
})
),
cond(
newResourceScopes.length > 0 &&
api.post(`${organizationRolePath}/resource-scopes`, {
json: { scopeIds: newResourceScopes },
})
),
].filter(Boolean)
).finally(() => {
setIsLoading(false);
});

mutate();
}, [
api,
mutate,
organizationRolePath,
organizationScopesAssignment.selectedData,
resourceScopesAssignment.selectedData,
]);

return {
activeTab,
setActiveTab,
isLoading,
organizationScopesAssignment,
resourceScopesAssignment,
clearSelectedData,
onSubmit,
};
}

export default useOrganizationRolePermissionsAssignment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type OrganizationScope } from '@logto/schemas';
import { useMemo, useState } from 'react';
import useSWR from 'swr';

function useOrganizationScopesAssignment(assignedScopes: OrganizationScope[] = []) {
const [selectedData, setSelectedData] = useState<OrganizationScope[]>([]);

const { data: organizationScopes } = useSWR<OrganizationScope[]>('api/organization-scopes');

const availableDataList = useMemo(
() =>
(organizationScopes ?? []).filter(
({ id }) => !assignedScopes.some((scope) => scope.id === id)
),
[organizationScopes, assignedScopes]
);

return {
selectedData,
setSelectedData,
availableDataList,
};
}

export default useOrganizationScopesAssignment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { isManagementApi, type Scope, type ResourceResponse } from '@logto/schemas';
import { useMemo, useState } from 'react';
import useSWR from 'swr';

import { type DataGroup } from '@/ds-components/DataTransferBox/type';

function useResourceScopesAssignment(assignedScopes?: Scope[]) {
const [selectedData, setSelectedData] = useState<Scope[]>([]);

const { data: allResources } = useSWR<ResourceResponse[]>('api/resources?includeScopes=true');

const availableDataGroups: Array<DataGroup<Scope>> = useMemo(() => {
if (!allResources) {
return [];
}

const resourcesWithScopes = allResources
// Filter out the management APIs
.filter((resource) => !isManagementApi(resource.indicator))
.map(({ name, scopes, id: resourceId }) => ({
groupId: resourceId,
groupName: name,
dataList: scopes
// Filter out the scopes that have been assigned
.filter(({ id: scopeId }) => !assignedScopes?.some((scope) => scope.id === scopeId)),
}));

// Filter out the resources that have no scopes
return resourcesWithScopes.filter(({ dataList }) => dataList.length > 0);
}, [allResources, assignedScopes]);

return {
selectedData,
setSelectedData,
availableDataGroups,
};
}

export default useResourceScopesAssignment;
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ActionsButton from '@/components/ActionsButton';
import Breakable from '@/components/Breakable';
import EditScopeModal, { type EditScopeData } from '@/components/EditScopeModal';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import OrganizationRolePermissionsAssignmentModal from '@/components/OrganizationRolePermissionsAssignmentModal';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import Search from '@/ds-components/Search';
Expand Down Expand Up @@ -77,6 +78,8 @@ function Permissions({ organizationRoleId }: Props) {
mutate();
};

const [isAssignScopesModalOpen, setIsAssignScopesModalOpen] = useState(false);

return (
<>
<Table
Expand Down Expand Up @@ -161,7 +164,7 @@ function Permissions({ organizationRoleId }: Props) {
type="primary"
icon={<Plus />}
onClick={() => {
// Todo @xiaoyijun Assign permissions to org role
setIsAssignScopesModalOpen(true);
}}
/>
</div>
Expand Down Expand Up @@ -199,6 +202,13 @@ function Permissions({ organizationRoleId }: Props) {
}}
/>
)}
<OrganizationRolePermissionsAssignmentModal
organizationRoleId={organizationRoleId}
isOpen={isAssignScopesModalOpen}
onClose={() => {
setIsAssignScopesModalOpen(false);
}}
/>
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type OrganizationRole } from '@logto/schemas';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
Expand All @@ -15,18 +16,29 @@ import { trySubmitSafe } from '@/utils/form';
type FormData = Pick<OrganizationRole, 'name' | 'description'>;

type Props = {
isOpen: boolean;
onClose: (createdOrganizationRole?: OrganizationRole) => void;
};

function CreateOrganizationRoleModal({ onClose }: Props) {
function CreateOrganizationRoleModal({ isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<FormData>();

const onCloseHandler = useCallback(
(createdData?: OrganizationRole) => {
// Reset form when modal is closed
reset();
onClose(createdData);
},
[onClose, reset]
);

const api = useApi();

const submit = handleSubmit(
Expand All @@ -37,17 +49,17 @@ function CreateOrganizationRoleModal({ onClose }: Props) {
toast.success(
t('organization_template.roles.create_modal.created', { name: createdData.name })
);
onClose(createdData);
onCloseHandler(createdData);
})
);

return (
<ReactModal
isOpen
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
onCloseHandler();
}}
>
<ModalLayout
Expand All @@ -60,7 +72,7 @@ function CreateOrganizationRoleModal({ onClose }: Props) {
onClick={submit}
/>
}
onClose={onClose}
onClose={onCloseHandler}
>
<FormField isRequired title="organization_template.roles.create_modal.name_field">
<TextInput
Expand Down
Loading

0 comments on commit a50f8f0

Please sign in to comment.