0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -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 { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
@ -10,24 +11,43 @@ import TextInput from '@/ds-components/TextInput';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
type Props = {
data: ScopeResponse;
onClose: () => void;
onSubmit: (scope: ScopeResponse) => Promise<void>;
export type EditScopeData = {
/** Only `description` is editable for all kinds of scopes */
description: Nullable<string>;
};
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 {
handleSubmit,
register,
formState: { isSubmitting },
} = useForm<ScopeResponse>({ defaultValues: data });
} = useForm<EditScopeData>({ defaultValues: data });
const onSubmitHandler = handleSubmit(
trySubmitSafe(async (formData) => {
await onSubmit({ ...data, ...formData });
await onSubmit(formData);
onClose();
})
);
@ -43,7 +63,7 @@ function EditPermissionModal({ data, onClose, onSubmit }: Props) {
}}
>
<ModalLayout
title="permissions.edit_title"
title={text.title}
footer={
<>
<Button isLoading={isSubmitting} title="general.cancel" onClick={onClose} />
@ -59,14 +79,14 @@ function EditPermissionModal({ data, onClose, onSubmit }: Props) {
onClose={onClose}
>
<form>
<FormField title="api_resource_details.permission.name">
<TextInput readOnly value={data.name} />
<FormField title={text.nameField}>
<TextInput readOnly value={scopeName} />
</FormField>
<FormField title="api_resource_details.permission.description">
<FormField title={text.descriptionField}>
<TextInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder={t('api_resource_details.permission.description_placeholder')}
placeholder={String(t(text.descriptionPlaceholder))}
{...register('description')}
/>
</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 ActionsButton from '../ActionsButton';
import EditScopeModal, { type EditScopeData } from '../EditScopeModal';
import EmptyDataPlaceholder from '../EmptyDataPlaceholder';
import EditPermissionModal from './EditPermissionModal';
import * as styles from './index.module.scss';
type SearchProps = {
@ -98,9 +98,9 @@ function PermissionsTable({
const api = useApi();
const handleEdit = async (scope: ScopeResponse) => {
const handleEdit = async (scope: ScopeResponse, editedData: EditScopeData) => {
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'));
onPermissionUpdated();
};
@ -236,12 +236,21 @@ function PermissionsTable({
onRetry={retryHandler}
/>
{editingScope && (
<EditPermissionModal
<EditScopeModal
scopeName={editingScope.name}
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={() => {
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 { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Plus from '@/assets/icons/plus.svg';
import ActionsButton from '@/components/ActionsButton';
import Breakable from '@/components/Breakable';
import EditScopeModal, { type EditScopeData } from '@/components/EditScopeModal';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ManageOrganizationPermissionModal from '@/components/ManageOrganizationPermissionModal';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table';
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 ResourceName from './ResourceName';
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 = {
organizationRoleId: string;
};
function Permissions({ organizationRoleId }: Props) {
const organizationRolePath = `api/organization-roles/${organizationRoleId}`;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useApi();
const { data, error, isLoading, mutate } = useSWR<OrganizationScope[], RequestError>(
`${organizationRolesPath}/${organizationRoleId}/scopes`
);
const { organizationScopes, resourceScopes, error, isLoading, mutate } =
useOrganizationRoleScopes(organizationRoleId);
const [{ keyword }, updateSearchParameters] = useSearchParametersWatcher({
keyword: '',
});
const filteredData = useMemo(() => {
if (keyword) {
return data?.filter((roleScope) => roleScope.name.includes(keyword));
}
return data;
}, [data, keyword]);
const filterScopesByKeyword = useCallback(
(scopes: OrganizationRoleScope[]) => scopes.filter(({ name }) => name.includes(keyword)),
[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(
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 (
<>
<Table
rowGroups={[{ key: 'organizationRolePermissions', data: filteredData }]}
rowGroups={[{ key: 'organizationRolePermissions', data: scopesData }]}
rowIndexKey="id"
columns={[
{
title: <DynamicT forKey="organization_role_details.permissions.name_column" />,
dataIndex: 'name',
colSpan: 7,
colSpan: 5,
render: ({ name }) => {
return (
<Tag variant="cell">
@ -83,9 +98,28 @@ function Permissions({ organizationRoleId }: Props) {
{
title: <DynamicT forKey="organization_role_details.permissions.description_column" />,
dataIndex: 'description',
colSpan: 8,
colSpan: 5,
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,
dataIndex: 'action',
@ -99,11 +133,9 @@ function Permissions({ organizationRoleId }: Props) {
deleteConfirmation: 'general.remove',
}}
onEdit={() => {
setEditPermission(scope);
}}
onDelete={async () => {
await scopeRemoveHandler(scope)();
setEditingScope(scope);
}}
onDelete={removeScopeHandler(scope)}
/>
),
},
@ -137,14 +169,33 @@ function Permissions({ organizationRoleId }: Props) {
placeholder={<EmptyDataPlaceholder />}
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
onRetry={async () => mutate(undefined, true)}
onRetry={mutate}
/>
{editPermission && (
<ManageOrganizationPermissionModal
data={editPermission}
{editingScope && (
<EditScopeModal
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={() => {
setEditPermission(undefined);
void mutate();
setEditingScope(undefined);
}}
/>
)}

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" />,
dataIndex: 'scopes',
colSpan: 12,
render: ({ scopes }) => {
return scopes.length === 0 ? (
render: ({ scopes, resourceScopes }) => {
const roleScopes = [...scopes, ...resourceScopes];
return roleScopes.length === 0 ? (
'-'
) : (
<div className={styles.permissions}>
{scopes.map(({ id, name }) => (
{roleScopes.map(({ id, name }) => (
<Tag key={id} variant="cell">
<Breakable>{name}</Breakable>
</Tag>