Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(console): assign permissions for org roles #5664

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
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,82 @@
import { cond } from '@silverhand/essentials';
import { useCallback, useMemo, 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 useMemo(
() => ({
activeTab,
setActiveTab,
isLoading,
organizationScopesAssignment,
resourceScopesAssignment,
clearSelectedData,
onSubmit,
}),
[
activeTab,
clearSelectedData,
isLoading,
onSubmit,
organizationScopesAssignment,
resourceScopesAssignment,
]
);
}

export default useOrganizationRolePermissionsAssignment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 useMemo(
() => ({
selectedData,
setSelectedData,
availableDataList,
}),
[selectedData, setSelectedData, availableDataList]
);
}

export default useOrganizationScopesAssignment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 useMemo(
() => ({
selectedData,
setSelectedData,
availableDataGroups,
}),
[availableDataGroups, selectedData]
);
}

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);
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
}}
/>
</>
);
}
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
Loading