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): display api resources in org role permission table #5671

Merged
Show file tree
Hide file tree
Changes from 2 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
@@ -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';
Expand All @@ -10,24 +11,43 @@ import TextInput from '@/ds-components/TextInput';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';

export type EditScopeData = {
/** Only `description` is editable for all kinds of scopes */
description: Nullable<string>;
};

type Props = {
data: ScopeResponse;
/** 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;
onSubmit: (scope: ScopeResponse) => Promise<void>;
};

function EditPermissionModal({ data, onClose, onSubmit }: Props) {
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();
})
);
Expand All @@ -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} />
Expand All @@ -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>
Expand All @@ -76,4 +96,4 @@ function EditPermissionModal({ data, onClose, onSubmit }: Props) {
);
}

export default EditPermissionModal;
export default EditScopeModal;
19 changes: 14 additions & 5 deletions packages/console/src/components/PermissionsTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
};
Expand Down Expand Up @@ -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);
xiaoyijun marked this conversation as resolved.
Show resolved Hide resolved
}}
onClose={() => {
setEditingScope(undefined);
}}
onSubmit={handleEdit}
/>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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 [editPermission, setEditPermission] = useState<OrganizationScope>();

const scopeRemoveHandler = useCallback(
(scopeToRemove: OrganizationScope) => async () => {
await api.put(`${organizationRolesPath}/${organizationRoleId}/scopes`, {
json: {
organizationScopeIds:
data?.filter((scope) => scope.id !== scopeToRemove.id).map(({ id }) => id) ?? [],
},
});
const filterScopes = useCallback(
(scopes: OrganizationRoleScope[]) => scopes.filter(({ name }) => name.includes(keyword)),
[keyword]
);

const filteredScopes = useMemo(
() =>
keyword
? [...filterScopes(resourceScopes), ...filterScopes(organizationScopes)]
: [...resourceScopes, ...organizationScopes],
[filterScopes, keyword, organizationScopes, resourceScopes]
);
xiaoyijun marked this conversation as resolved.
Show resolved Hide resolved

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}`);

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: filteredScopes }]}
rowIndexKey="id"
columns={[
{
title: <DynamicT forKey="organization_role_details.permissions.name_column" />,
dataIndex: 'name',
colSpan: 7,
colSpan: 5,
render: ({ name }) => {
return (
<Tag variant="cell">
Expand All @@ -83,9 +98,28 @@
{
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',
Expand All @@ -99,11 +133,9 @@
deleteConfirmation: 'general.remove',
}}
onEdit={() => {
setEditPermission(scope);
}}
onDelete={async () => {
await scopeRemoveHandler(scope)();
setEditingScope(scope);
}}
onDelete={removeScopeHandler(scope)}
/>
),
},
Expand All @@ -129,7 +161,7 @@
type="primary"
icon={<Plus />}
onClick={() => {
// Todo @xiaoyijun Assign permissions to org role

Check warning on line 164 in packages/console/src/pages/OrganizationRoleDetails/Permissions/index.tsx

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/console/src/pages/OrganizationRoleDetails/Permissions/index.tsx#L164

[no-warning-comments] Unexpected 'todo' comment: 'Todo @xiaoyijun Assign permissions to...'.
}}
/>
</div>
Expand All @@ -137,14 +169,33 @@
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);
}}
/>
)}
Expand Down
Loading
Loading