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:
parent
01fee1dd9d
commit
dd08efe050
7 changed files with 219 additions and 60 deletions
|
@ -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;
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue