0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat(console): display api resources in org role permission table (#5671)

This commit is contained in:
Xiao Yijun 2024-04-11 08:42:31 +08:00 committed by GitHub
parent 01fee1dd9d
commit dd08efe050
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 219 additions and 60 deletions

View file

@ -1,4 +1,5 @@
import { type ScopeResponse } from '@logto/schemas'; import { type AdminConsoleKey } from '@logto/phrases';
import { type Nullable } from '@silverhand/essentials';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
@ -10,24 +11,43 @@ import TextInput from '@/ds-components/TextInput';
import * as modalStyles from '@/scss/modal.module.scss'; import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form'; import { trySubmitSafe } from '@/utils/form';
type Props = { export type EditScopeData = {
data: ScopeResponse; /** Only `description` is editable for all kinds of scopes */
onClose: () => void; description: Nullable<string>;
onSubmit: (scope: ScopeResponse) => Promise<void>;
}; };
function EditPermissionModal({ data, onClose, onSubmit }: Props) { type Props = {
/** The scope name displayed in the name input field */
scopeName: string;
/** The data to edit */
data: EditScopeData;
/** Determines the translation keys for texts in the editor modal */
text: {
/** The translation key of the modal title */
title: AdminConsoleKey;
/** The field name translation key for the name input */
nameField: AdminConsoleKey;
/** The field name translation key for the description input */
descriptionField: AdminConsoleKey;
/** The placeholder translation key for the description input */
descriptionPlaceholder: AdminConsoleKey;
};
onSubmit: (editedData: EditScopeData) => Promise<void>;
onClose: () => void;
};
function EditScopeModal({ scopeName, data, text, onClose, onSubmit }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { const {
handleSubmit, handleSubmit,
register, register,
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm<ScopeResponse>({ defaultValues: data }); } = useForm<EditScopeData>({ defaultValues: data });
const onSubmitHandler = handleSubmit( const onSubmitHandler = handleSubmit(
trySubmitSafe(async (formData) => { trySubmitSafe(async (formData) => {
await onSubmit({ ...data, ...formData }); await onSubmit(formData);
onClose(); onClose();
}) })
); );
@ -43,7 +63,7 @@ function EditPermissionModal({ data, onClose, onSubmit }: Props) {
}} }}
> >
<ModalLayout <ModalLayout
title="permissions.edit_title" title={text.title}
footer={ footer={
<> <>
<Button isLoading={isSubmitting} title="general.cancel" onClick={onClose} /> <Button isLoading={isSubmitting} title="general.cancel" onClick={onClose} />
@ -59,14 +79,14 @@ function EditPermissionModal({ data, onClose, onSubmit }: Props) {
onClose={onClose} onClose={onClose}
> >
<form> <form>
<FormField title="api_resource_details.permission.name"> <FormField title={text.nameField}>
<TextInput readOnly value={data.name} /> <TextInput readOnly value={scopeName} />
</FormField> </FormField>
<FormField title="api_resource_details.permission.description"> <FormField title={text.descriptionField}>
<TextInput <TextInput
// eslint-disable-next-line jsx-a11y/no-autofocus // eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus autoFocus
placeholder={t('api_resource_details.permission.description_placeholder')} placeholder={String(t(text.descriptionPlaceholder))}
{...register('description')} {...register('description')}
/> />
</FormField> </FormField>
@ -76,4 +96,4 @@ function EditPermissionModal({ data, onClose, onSubmit }: Props) {
); );
} }
export default EditPermissionModal; export default EditScopeModal;

View file

@ -21,9 +21,9 @@ import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url'; import useDocumentationUrl from '@/hooks/use-documentation-url';
import ActionsButton from '../ActionsButton'; import ActionsButton from '../ActionsButton';
import EditScopeModal, { type EditScopeData } from '../EditScopeModal';
import EmptyDataPlaceholder from '../EmptyDataPlaceholder'; import EmptyDataPlaceholder from '../EmptyDataPlaceholder';
import EditPermissionModal from './EditPermissionModal';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
type SearchProps = { type SearchProps = {
@ -98,9 +98,9 @@ function PermissionsTable({
const api = useApi(); const api = useApi();
const handleEdit = async (scope: ScopeResponse) => { const handleEdit = async (scope: ScopeResponse, editedData: EditScopeData) => {
const patchApiEndpoint = `api/resources/${scope.resourceId}/scopes/${scope.id}`; const patchApiEndpoint = `api/resources/${scope.resourceId}/scopes/${scope.id}`;
await api.patch(patchApiEndpoint, { json: scope }); await api.patch(patchApiEndpoint, { json: editedData });
toast.success(t('permissions.updated')); toast.success(t('permissions.updated'));
onPermissionUpdated(); onPermissionUpdated();
}; };
@ -236,12 +236,21 @@ function PermissionsTable({
onRetry={retryHandler} onRetry={retryHandler}
/> />
{editingScope && ( {editingScope && (
<EditPermissionModal <EditScopeModal
scopeName={editingScope.name}
data={editingScope} data={editingScope}
text={{
title: 'permissions.edit_title',
nameField: 'api_resource_details.permission.name',
descriptionField: 'api_resource_details.permission.description',
descriptionPlaceholder: 'api_resource_details.permission.description_placeholder',
}}
onSubmit={async (editedData) => {
await handleEdit(editingScope, editedData);
}}
onClose={() => { onClose={() => {
setEditingScope(undefined); setEditingScope(undefined);
}} }}
onSubmit={handleEdit}
/> />
)} )}
</> </>

View file

@ -0,0 +1,12 @@
@use '@/scss/underscore' as _;
.container {
margin-left: _.unit(2);
color: var(--color-text-secondary);
.icon {
width: 16px;
height: 16px;
margin-right: _.unit(1);
}
}

View file

@ -0,0 +1,27 @@
import { type Resource } from '@logto/schemas';
import useSWR from 'swr';
import ResourceIcon from '@/assets/icons/resource.svg';
import * as styles from './index.module.scss';
type Props = {
resourceId: string;
};
function ResourceName({ resourceId }: Props) {
const { data, isLoading } = useSWR<Resource>(`api/resources/${resourceId}`);
if (isLoading || !data) {
return null;
}
return (
<span className={styles.container}>
<ResourceIcon className={styles.icon} />
{data.name}
</span>
);
}
export default ResourceName;

View file

@ -1,77 +1,92 @@
import { type OrganizationScope } from '@logto/schemas'; import { type Scope, type OrganizationScope } from '@logto/schemas';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Plus from '@/assets/icons/plus.svg'; import Plus from '@/assets/icons/plus.svg';
import ActionsButton from '@/components/ActionsButton'; import ActionsButton from '@/components/ActionsButton';
import Breakable from '@/components/Breakable'; import Breakable from '@/components/Breakable';
import EditScopeModal, { type EditScopeData } from '@/components/EditScopeModal';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder'; import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ManageOrganizationPermissionModal from '@/components/ManageOrganizationPermissionModal';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import Search from '@/ds-components/Search'; import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table'; import Table from '@/ds-components/Table';
import Tag from '@/ds-components/Tag'; import Tag from '@/ds-components/Tag';
import useApi, { type RequestError } from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher'; import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import ResourceName from './ResourceName';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
import useOrganizationRoleScopes from './use-organization-role-scopes';
const organizationRolesPath = 'api/organization-roles'; type OrganizationRoleScope = OrganizationScope | Scope;
const isResourceScope = (scope: OrganizationRoleScope): scope is Scope => 'resourceId' in scope;
type Props = { type Props = {
organizationRoleId: string; organizationRoleId: string;
}; };
function Permissions({ organizationRoleId }: Props) { function Permissions({ organizationRoleId }: Props) {
const organizationRolePath = `api/organization-roles/${organizationRoleId}`;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useApi(); const api = useApi();
const { data, error, isLoading, mutate } = useSWR<OrganizationScope[], RequestError>( const { organizationScopes, resourceScopes, error, isLoading, mutate } =
`${organizationRolesPath}/${organizationRoleId}/scopes` useOrganizationRoleScopes(organizationRoleId);
);
const [{ keyword }, updateSearchParameters] = useSearchParametersWatcher({ const [{ keyword }, updateSearchParameters] = useSearchParametersWatcher({
keyword: '', keyword: '',
}); });
const filteredData = useMemo(() => { const filterScopesByKeyword = useCallback(
if (keyword) { (scopes: OrganizationRoleScope[]) => scopes.filter(({ name }) => name.includes(keyword)),
return data?.filter((roleScope) => roleScope.name.includes(keyword)); [keyword]
} );
return data;
}, [data, keyword]);
const [editPermission, setEditPermission] = useState<OrganizationScope>(); const scopesData = useMemo(
() =>
keyword
? [...filterScopesByKeyword(resourceScopes), ...filterScopesByKeyword(organizationScopes)]
: [...resourceScopes, ...organizationScopes],
[filterScopesByKeyword, keyword, organizationScopes, resourceScopes]
);
const [editingScope, setEditingScope] = useState<OrganizationScope>();
const removeScopeHandler = useCallback(
(scopeToRemove: OrganizationRoleScope) => async () => {
const deleteSubpath = isResourceScope(scopeToRemove) ? 'resource-scopes' : 'scopes';
await api.delete(`${organizationRolePath}/${deleteSubpath}/${scopeToRemove.id}`);
const scopeRemoveHandler = useCallback(
(scopeToRemove: OrganizationScope) => async () => {
await api.put(`${organizationRolesPath}/${organizationRoleId}/scopes`, {
json: {
organizationScopeIds:
data?.filter((scope) => scope.id !== scopeToRemove.id).map(({ id }) => id) ?? [],
},
});
toast.success( toast.success(
t('organization_role_details.permissions.removed', { name: scopeToRemove.name }) t('organization_role_details.permissions.removed', { name: scopeToRemove.name })
); );
void mutate(); mutate();
}, },
[api, data, mutate, organizationRoleId, t] [api, mutate, organizationRolePath, t]
); );
const handleEdit = async (scope: OrganizationRoleScope, editedData: EditScopeData) => {
const patchApiEndpoint = isResourceScope(scope)
? `api/resources/${scope.resourceId}/scopes/${scope.id}`
: `api/organization-scopes/${scope.id}`;
await api.patch(patchApiEndpoint, { json: editedData });
toast.success(t('permissions.updated'));
mutate();
};
return ( return (
<> <>
<Table <Table
rowGroups={[{ key: 'organizationRolePermissions', data: filteredData }]} rowGroups={[{ key: 'organizationRolePermissions', data: scopesData }]}
rowIndexKey="id" rowIndexKey="id"
columns={[ columns={[
{ {
title: <DynamicT forKey="organization_role_details.permissions.name_column" />, title: <DynamicT forKey="organization_role_details.permissions.name_column" />,
dataIndex: 'name', dataIndex: 'name',
colSpan: 7, colSpan: 5,
render: ({ name }) => { render: ({ name }) => {
return ( return (
<Tag variant="cell"> <Tag variant="cell">
@ -83,9 +98,28 @@ function Permissions({ organizationRoleId }: Props) {
{ {
title: <DynamicT forKey="organization_role_details.permissions.description_column" />, title: <DynamicT forKey="organization_role_details.permissions.description_column" />,
dataIndex: 'description', dataIndex: 'description',
colSpan: 8, colSpan: 5,
render: ({ description }) => <Breakable>{description ?? '-'}</Breakable>, render: ({ description }) => <Breakable>{description ?? '-'}</Breakable>,
}, },
{
title: <DynamicT forKey="organization_role_details.permissions.type_column" />,
dataIndex: 'type',
colSpan: 5,
render: (scope) => {
return (
<Breakable>
{isResourceScope(scope) ? (
<>
<DynamicT forKey="organization_role_details.permissions.type.api" />
<ResourceName resourceId={scope.resourceId} />
</>
) : (
<DynamicT forKey="organization_role_details.permissions.type.org" />
)}
</Breakable>
);
},
},
{ {
title: null, title: null,
dataIndex: 'action', dataIndex: 'action',
@ -99,11 +133,9 @@ function Permissions({ organizationRoleId }: Props) {
deleteConfirmation: 'general.remove', deleteConfirmation: 'general.remove',
}} }}
onEdit={() => { onEdit={() => {
setEditPermission(scope); setEditingScope(scope);
}}
onDelete={async () => {
await scopeRemoveHandler(scope)();
}} }}
onDelete={removeScopeHandler(scope)}
/> />
), ),
}, },
@ -137,14 +169,33 @@ function Permissions({ organizationRoleId }: Props) {
placeholder={<EmptyDataPlaceholder />} placeholder={<EmptyDataPlaceholder />}
isLoading={isLoading} isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message} errorMessage={error?.body?.message ?? error?.message}
onRetry={async () => mutate(undefined, true)} onRetry={mutate}
/> />
{editPermission && ( {editingScope && (
<ManageOrganizationPermissionModal <EditScopeModal
data={editPermission} scopeName={editingScope.name}
data={editingScope}
text={
isResourceScope(editingScope)
? {
title: 'permissions.edit_title',
nameField: 'api_resource_details.permission.name',
descriptionField: 'api_resource_details.permission.description',
descriptionPlaceholder: 'api_resource_details.permission.description_placeholder',
}
: {
title: 'organization_template.permissions.edit_title',
nameField: 'organization_template.permissions.permission_field_name',
descriptionField: 'organization_template.permissions.description_field_name',
descriptionPlaceholder:
'organization_template.permissions.description_field_placeholder',
}
}
onSubmit={async (editedData) => {
await handleEdit(editingScope, editedData);
}}
onClose={() => { onClose={() => {
setEditPermission(undefined); setEditingScope(undefined);
void mutate();
}} }}
/> />
)} )}

View file

@ -0,0 +1,38 @@
import { type Scope, type OrganizationScope } from '@logto/schemas';
import useSWR from 'swr';
import { type RequestError } from '@/hooks/use-api';
/**
* Fetches the organization and resource scopes of an organization role.
*/
function useOrganizationRoleScopes(organizationRoleId: string) {
const organizationRolePath = `api/organization-roles/${organizationRoleId}`;
const {
data: organizationScopes = [],
error: fetchOrganizationScopesError,
isLoading: isOrganizationScopesLoading,
mutate: mutateOrganizationScopes,
} = useSWR<OrganizationScope[], RequestError>(`${organizationRolePath}/scopes`);
const {
data: resourceScopes = [],
error: fetchResourceScopesError,
isLoading: isResourceScopesLoading,
mutate: mutateResourceScopes,
} = useSWR<Scope[], RequestError>(`${organizationRolePath}/resource-scopes`);
return {
organizationScopes,
resourceScopes,
error: fetchOrganizationScopesError ?? fetchResourceScopesError,
isLoading: isOrganizationScopesLoading || isResourceScopesLoading,
mutate: () => {
void mutateOrganizationScopes();
void mutateResourceScopes();
},
};
}
export default useOrganizationRoleScopes;

View file

@ -70,12 +70,14 @@ function OrganizationRoles() {
title: <DynamicT forKey="organization_template.roles.permissions_column" />, title: <DynamicT forKey="organization_template.roles.permissions_column" />,
dataIndex: 'scopes', dataIndex: 'scopes',
colSpan: 12, colSpan: 12,
render: ({ scopes }) => { render: ({ scopes, resourceScopes }) => {
return scopes.length === 0 ? ( const roleScopes = [...scopes, ...resourceScopes];
return roleScopes.length === 0 ? (
'-' '-'
) : ( ) : (
<div className={styles.permissions}> <div className={styles.permissions}>
{scopes.map(({ id, name }) => ( {roleScopes.map(({ id, name }) => (
<Tag key={id} variant="cell"> <Tag key={id} variant="cell">
<Breakable>{name}</Breakable> <Breakable>{name}</Breakable>
</Tag> </Tag>