0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(console): support permission editing (#5567)

This commit is contained in:
Xiao Yijun 2024-03-28 10:52:15 +08:00 committed by GitHub
parent f83e85ba55
commit ba966fdefe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 357 additions and 156 deletions

View file

@ -0,0 +1,79 @@
import { type ScopeResponse } from '@logto/schemas';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
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>;
};
function EditPermissionModal({ data, onClose, onSubmit }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
handleSubmit,
register,
formState: { isSubmitting },
} = useForm<ScopeResponse>({ defaultValues: data });
const onSubmitHandler = handleSubmit(
trySubmitSafe(async (formData) => {
await onSubmit({ ...data, ...formData });
onClose();
})
);
return (
<ReactModal
shouldCloseOnEsc
isOpen={Boolean(data)}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
}}
>
<ModalLayout
title="permissions.edit_title"
footer={
<>
<Button isLoading={isSubmitting} title="general.cancel" onClick={onClose} />
<Button
isLoading={isSubmitting}
title="general.save"
type="primary"
htmlType="submit"
onClick={onSubmitHandler}
/>
</>
}
onClose={onClose}
>
<form>
<FormField title="api_resource_details.permission.name">
<TextInput readOnly value={data.name} />
</FormField>
<FormField title="api_resource_details.permission.description">
<TextInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder={t('api_resource_details.permission.description_placeholder')}
{...register('description')}
/>
</FormField>
</form>
</ModalLayout>
</ReactModal>
);
}
export default EditPermissionModal;

View file

@ -24,7 +24,7 @@
@include _.text-ellipsis;
}
.deleteColumn {
.actionColumn {
text-align: right;
}
}

View file

@ -1,16 +1,15 @@
import type { AdminConsoleKey } from '@logto/phrases';
import type { ScopeResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Delete from '@/assets/icons/delete.svg';
import Plus from '@/assets/icons/plus.svg';
import PermissionsEmptyDark from '@/assets/images/permissions-empty-dark.svg';
import PermissionsEmpty from '@/assets/images/permissions-empty.svg';
import { ApiResourceDetailsTabs } from '@/consts/page-tabs';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import IconButton from '@/ds-components/IconButton';
import type { Props as PaginationProps } from '@/ds-components/Pagination';
import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table';
@ -18,11 +17,13 @@ import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
import type { Column } from '@/ds-components/Table/types';
import Tag from '@/ds-components/Tag';
import TextLink from '@/ds-components/TextLink';
import { Tooltip } from '@/ds-components/Tip';
import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import ActionsButton from '../ActionsButton';
import EmptyDataPlaceholder from '../EmptyDataPlaceholder';
import EditPermissionModal from './EditPermissionModal';
import * as styles from './index.module.scss';
type SearchProps = {
@ -32,19 +33,47 @@ type SearchProps = {
};
type Props = {
/** List of permissions to be displayed in the table. */
scopes?: ScopeResponse[];
/** Whether the table is loading data or not. */
isLoading: boolean;
/** Error message to be displayed when the table fails to load data. */
errorMessage?: string;
/** The translation key of the create button. */
createButtonTitle: AdminConsoleKey;
deleteButtonTitle?: AdminConsoleKey;
/** Whether the table is read-only or not.
* If true, the table will not display the create button and action buttons (editing & deletion).
*/
isReadOnly?: boolean;
/** Whether the API column is visible or not.
* The API column displays the API resource that the permission belongs to.
*/
isApiColumnVisible?: boolean;
/** Whether the create guide is visible or not.
* If true, the table will display a placeholder guiding the user to create a new permission if no permissions are found.
*/
isCreateGuideVisible?: boolean;
/** Pagination related props, used to navigate through the permissions in the table. */
pagination?: PaginationProps;
/** Search related props, used to filter the permissions in the table. */
search: SearchProps;
/** Function that will be called when the create button is clicked. */
createHandler: () => void;
deleteHandler: (ScopeResponse: ScopeResponse) => void;
/** Callback function that will be called when a permission is going to be deleted. */
deleteHandler: (scope: ScopeResponse) => void;
/** Function that will be called when the retry button is click. */
retryHandler: () => void;
/** Callback function that will be called when the permission is updated (edited). */
onPermissionUpdated: () => void;
/** Specify deletion related text */
deletionText: {
/** Delete button title in the action list */
actionButton: AdminConsoleKey;
/** Confirmation content in the deletion confirmation modal */
confirmation: AdminConsoleKey;
/** Confirmation button title in the deletion confirmation modal */
confirmButton: AdminConsoleKey;
};
};
function PermissionsTable({
@ -52,7 +81,6 @@ function PermissionsTable({
isLoading,
errorMessage,
createButtonTitle,
deleteButtonTitle = 'general.delete',
isReadOnly = false,
isApiColumnVisible = false,
isCreateGuideVisible = false,
@ -61,9 +89,21 @@ function PermissionsTable({
createHandler,
deleteHandler,
retryHandler,
onPermissionUpdated,
deletionText,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
const [editingScope, setEditingScope] = useState<ScopeResponse>();
const api = useApi();
const handleEdit = async (scope: ScopeResponse) => {
const patchApiEndpoint = `api/resources/${scope.resourceId}/scopes/${scope.id}`;
await api.patch(patchApiEndpoint, { json: scope });
toast.success(t('permissions.updated'));
onPermissionUpdated();
};
const nameColumn: Column<ScopeResponse> = {
title: t('permissions.name_column'),
@ -93,25 +133,31 @@ function PermissionsTable({
),
};
const deleteColumn: Column<ScopeResponse> = {
const actionColumn: Column<ScopeResponse> = {
title: null,
dataIndex: 'delete',
colSpan: 2,
className: styles.deleteColumn,
dataIndex: 'action',
colSpan: 1,
className: styles.actionColumn,
render: (scope) =>
/**
* When the table is read-only, hide the delete button rather than the whole column to keep the table column spaces.
*/
isReadOnly ? null : (
<Tooltip content={<DynamicT forKey={deleteButtonTitle} />}>
<IconButton
onClick={() => {
deleteHandler(scope);
}}
>
<Delete />
</IconButton>
</Tooltip>
<ActionsButton
fieldName="permissions.name_column"
deleteConfirmation={deletionText.confirmation}
textOverrides={{
edit: 'permissions.edit',
delete: deletionText.actionButton,
deleteConfirmation: deletionText.confirmButton,
}}
onDelete={() => {
deleteHandler(scope);
}}
onEdit={() => {
setEditingScope(scope);
}}
/>
),
};
@ -119,59 +165,34 @@ function PermissionsTable({
nameColumn,
descriptionColumn,
conditional(isApiColumnVisible && apiColumn),
deleteColumn,
actionColumn,
// eslint-disable-next-line unicorn/prefer-native-coercion-functions
].filter((column): column is Column<ScopeResponse> => Boolean(column));
return (
<Table
className={styles.permissionTable}
rowIndexKey="id"
rowGroups={[{ key: 'scopes', data: scopes }]}
columns={columns}
filter={
<div className={styles.filter}>
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t(
isApiColumnVisible
? 'permissions.search_placeholder'
: 'permissions.search_placeholder_without_api'
)}
onSearch={searchHandler}
onClearSearch={clearSearchHandler}
/>
{!isReadOnly && (
<Button
title={createButtonTitle}
className={styles.createButton}
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
createHandler();
}}
<>
<Table
className={styles.permissionTable}
rowIndexKey="id"
rowGroups={[{ key: 'scopes', data: scopes }]}
columns={columns}
filter={
<div className={styles.filter}>
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t(
isApiColumnVisible
? 'permissions.search_placeholder'
: 'permissions.search_placeholder_without_api'
)}
onSearch={searchHandler}
onClearSearch={clearSearchHandler}
/>
)}
</div>
}
isLoading={isLoading}
pagination={pagination}
placeholder={
!isReadOnly && isCreateGuideVisible ? (
<TablePlaceholder
image={<PermissionsEmpty />}
imageDark={<PermissionsEmptyDark />}
title="permissions.placeholder_title"
description="permissions.placeholder_description"
learnMoreLink={{
href: getDocumentationUrl('/docs/recipes/rbac/manage-permissions-and-roles'),
targetBlank: 'noopener',
}}
action={
{!isReadOnly && (
<Button
title={createButtonTitle}
className={styles.createButton}
type="primary"
size="large"
icon={<Plus />}
@ -179,15 +200,51 @@ function PermissionsTable({
createHandler();
}}
/>
}
/>
) : (
<EmptyDataPlaceholder />
)
}
errorMessage={errorMessage}
onRetry={retryHandler}
/>
)}
</div>
}
isLoading={isLoading}
pagination={pagination}
placeholder={
!isReadOnly && isCreateGuideVisible ? (
<TablePlaceholder
image={<PermissionsEmpty />}
imageDark={<PermissionsEmptyDark />}
title="permissions.placeholder_title"
description="permissions.placeholder_description"
learnMoreLink={{
href: getDocumentationUrl('/docs/recipes/rbac/manage-permissions-and-roles'),
targetBlank: 'noopener',
}}
action={
<Button
title={createButtonTitle}
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
createHandler();
}}
/>
}
/>
) : (
<EmptyDataPlaceholder />
)
}
errorMessage={errorMessage}
onRetry={retryHandler}
/>
{editingScope && (
<EditPermissionModal
data={editingScope}
onClose={() => {
setEditingScope(undefined);
}}
onSubmit={handleEdit}
/>
)}
</>
);
}

View file

@ -1,4 +1,4 @@
import type { Scope, ScopeResponse } from '@logto/schemas';
import type { ScopeResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
@ -8,7 +8,6 @@ import useSWR from 'swr';
import PermissionsTable from '@/components/PermissionsTable';
import { defaultPageSize } from '@/consts';
import ConfirmModal from '@/ds-components/ConfirmModal';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
@ -48,23 +47,11 @@ function ApiResourcePermissions() {
const api = useApi();
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
const [scopeToBeDeleted, setScopeToBeDeleted] = useState<Scope>();
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
if (!scopeToBeDeleted || isDeleting) {
return;
}
setIsDeleting(true);
try {
await api.delete(`api/resources/${resourceId}/scopes/${scopeToBeDeleted.id}`);
toast.success(t('api_resource_details.permission.deleted', { name: scopeToBeDeleted.name }));
await mutate();
setScopeToBeDeleted(undefined);
} finally {
setIsDeleting(false);
}
const handleDelete = async (scopeToBeDeleted: ScopeResponse) => {
await api.delete(`api/resources/${resourceId}/scopes/${scopeToBeDeleted.id}`);
toast.success(t('api_resource_details.permission.deleted', { name: scopeToBeDeleted.name }));
await mutate();
};
return (
@ -78,7 +65,12 @@ function ApiResourcePermissions() {
createHandler={() => {
setIsCreateFormOpen(true);
}}
deleteHandler={setScopeToBeDeleted}
deletionText={{
actionButton: 'permissions.delete',
confirmation: 'api_resource_details.permission.delete_description',
confirmButton: 'general.delete',
}}
deleteHandler={handleDelete}
errorMessage={error?.body?.message ?? error?.message}
retryHandler={async () => mutate(undefined, true)}
pagination={{
@ -104,6 +96,7 @@ function ApiResourcePermissions() {
});
},
}}
onPermissionUpdated={mutate}
/>
{isCreateFormOpen && totalCount !== undefined && (
<CreatePermissionModal
@ -120,19 +113,6 @@ function ApiResourcePermissions() {
}}
/>
)}
{scopeToBeDeleted && (
<ConfirmModal
isOpen
isLoading={isDeleting}
confirmButtonText="general.delete"
onCancel={() => {
setScopeToBeDeleted(undefined);
}}
onConfirm={handleDelete}
>
{t('api_resource_details.permission.delete_description')}
</ConfirmModal>
)}
</>
);
}

View file

@ -1,4 +1,4 @@
import type { Scope, ScopeResponse } from '@logto/schemas';
import type { ScopeResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
@ -8,7 +8,6 @@ import useSWR from 'swr';
import PermissionsTable from '@/components/PermissionsTable';
import { defaultPageSize } from '@/consts';
import ConfirmModal from '@/ds-components/ConfirmModal';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
@ -46,27 +45,13 @@ function RolePermissions() {
const [scopes, totalCount] = data ?? [];
const [isAssignPermissionsModalOpen, setIsAssignPermissionsModalOpen] = useState(false);
const [scopeToBeDeleted, setScopeToBeDeleted] = useState<Scope>();
const [isDeleting, setIsDeleting] = useState(false);
const api = useApi();
const handleDelete = async () => {
if (!scopeToBeDeleted || isDeleting) {
return;
}
setIsDeleting(true);
try {
await api.delete(`api/roles/${roleId}/scopes/${scopeToBeDeleted.id}`);
toast.success(
t('role_details.permission.permission_deleted', { name: scopeToBeDeleted.name })
);
await mutate();
setScopeToBeDeleted(undefined);
} finally {
setIsDeleting(false);
}
const handleDelete = async (scope: ScopeResponse) => {
await api.delete(`api/roles/${roleId}/scopes/${scope.id}`);
toast.success(t('role_details.permission.permission_deleted', { name: scope.name }));
await mutate();
};
return (
@ -76,11 +61,15 @@ function RolePermissions() {
scopes={scopes}
isLoading={isLoading}
createButtonTitle="role_details.permission.assign_button"
deleteButtonTitle="general.remove"
deletionText={{
actionButton: 'permissions.remove',
confirmation: 'role_details.permission.deletion_description',
confirmButton: 'general.remove',
}}
createHandler={() => {
setIsAssignPermissionsModalOpen(true);
}}
deleteHandler={setScopeToBeDeleted}
deleteHandler={handleDelete}
errorMessage={error?.body?.message ?? error?.message}
retryHandler={async () => mutate(undefined, true)}
pagination={{
@ -100,20 +89,8 @@ function RolePermissions() {
updateSearchParameters({ keyword: '', page: 1 });
},
}}
onPermissionUpdated={mutate}
/>
{scopeToBeDeleted && (
<ConfirmModal
isOpen
isLoading={isDeleting}
confirmButtonText="general.remove"
onCancel={() => {
setScopeToBeDeleted(undefined);
}}
onConfirm={handleDelete}
>
{t('role_details.permission.deletion_description')}
</ConfirmModal>
)}
{isAssignPermissionsModalOpen && totalCount !== undefined && (
<AssignPermissionsModal
roleId={roleId}

View file

@ -6,8 +6,23 @@ import {
expectToClickModalAction,
waitForToast,
} from '#src/ui-helpers/index.js';
import { selectDropdownMenuItem } from '#src/ui-helpers/select-dropdown-menu-item.js';
import { expectNavigation, appendPathname } from '#src/utils.js';
export const expectToSelectPermissionAction = async (
page: Page,
{ permissionName, action }: { permissionName: string; action: string }
) => {
const permissionRow = await expect(page).toMatchElement('table tbody tr:has(td div)', {
text: permissionName,
});
// Click the action button from the permission row
await expect(permissionRow).toClick('td[class$=actionColumn] button');
await selectDropdownMenuItem(page, 'div[role=menuitem]', action);
};
/**
* Create a machine-to-machine role and assign permissions to it by operating on the Web
*

View file

@ -21,7 +21,7 @@ import {
expectToProceedApplicationCreationFrom,
} from '../applications/helpers.js';
import { createM2mRoleAndAssignPermissions } from './utils.js';
import { createM2mRoleAndAssignPermissions, expectToSelectPermissionAction } from './helper.js';
await page.setViewport({ width: 1920, height: 1080 });
@ -193,10 +193,10 @@ describe('M2M RBAC', () => {
text: 'Permissions',
});
const permissionRow = await expect(page).toMatchElement('table tbody tr:has(td div)', {
text: permissionName,
await expectToSelectPermissionAction(page, {
permissionName,
action: 'Remove permission',
});
await expect(permissionRow).toClick('td[class$=deleteColumn] button');
await expectConfirmModalAndAct(page, {
title: 'Reminder',

View file

@ -17,9 +17,11 @@ import {
generateRoleName,
} from '#src/utils.js';
import { expectToSelectPermissionAction } from './helper.js';
await page.setViewport({ width: 1920, height: 1080 });
describe('RBAC', () => {
describe('User RBAC', () => {
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
const apiResourceName = generateResourceName();
const apiResourceIndicator = generateResourceIndicator();
@ -103,6 +105,18 @@ describe('RBAC', () => {
});
});
it('be able to edit the permission description', async () => {
await expectToSelectPermissionAction(page, { permissionName, action: 'Edit permission' });
await expectModalWithTitle(page, 'Edit API permission');
const newDescription = `New: ${permissionDescription}`;
await expect(page).toFillForm('.ReactModalPortal form', { description: newDescription });
await expect(page).toClick('.ReactModalPortal button[type=submit]');
await waitForToast(page, { text: 'Permission updated.', type: 'success' });
await expect(page).toMatchElement('table tbody tr td div', {
text: newDescription,
});
});
it('navigate to user management page', async () => {
await expectNavigation(page.goto(appendPathname('/console/users', logtoConsoleUrl).href));
await expect(page).toMatchElement(
@ -183,10 +197,10 @@ describe('RBAC', () => {
text: 'Permissions',
});
const permissionRow = await expect(page).toMatchElement('table tbody tr:has(td div)', {
text: permissionName,
await expectToSelectPermissionAction(page, {
permissionName,
action: 'Remove permission',
});
await expect(permissionRow).toClick('td[class$=deleteColumn] button');
await expectConfirmModalAndAct(page, {
title: 'Reminder',
@ -378,12 +392,16 @@ describe('RBAC', () => {
await expect(page).toClick('nav div[class$=item] div[class$=link] a', {
text: 'Permissions',
});
const permissionRow = await expect(page).toMatchElement('table tbody tr:has(td div)', {
text: permissionName,
});
await expect(permissionRow).toClick('td[class$=deleteColumn] button');
await expectConfirmModalAndAct(page, { title: 'Reminder', actionText: 'Delete' });
await expectToSelectPermissionAction(page, {
permissionName,
action: 'Delete permission',
});
await expectConfirmModalAndAct(page, {
title: 'Reminder',
actionText: 'Delete',
});
await waitForToast(page, {
text: `The permission "${permissionName}" was successfully deleted.`,

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Berechtigung',
placeholder_description:
'Berechtigung bezieht sich auf die Autorisierung zum Zugriff auf eine Ressource (wir nennen sie API-Ressource).',
edit: 'Bearbeitungsberechtigung',
delete: 'Löschberechtigung',
remove: 'Entfernungsberechtigung',
updated: 'Berechtigung aktualisiert.',
edit_title: 'API-Berechtigung bearbeiten',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Permission',
placeholder_description:
'Permission refers to the authorization to access a resource (we call it API resource).',
edit: 'Edit permission',
delete: 'Delete permission',
remove: 'Remove permission',
updated: 'Permission updated.',
edit_title: 'Edit API permission',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Permiso',
placeholder_description:
'Permiso se refiere a la autorización para acceder a un recurso (lo llamamos recurso de API).',
edit: 'Permiso de edición',
delete: 'Permiso de eliminación',
remove: 'Permiso de remover',
updated: 'Permiso actualizado.',
edit_title: 'Editar permiso de API',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Permission',
placeholder_description:
"La permission fait référence à l'autorisation d'accéder à une ressource (nous l'appelons ressource d'API).",
edit: 'Permission de modifier',
delete: 'Permission de supprimer',
remove: 'Permission de retirer',
updated: 'Permission mise à jour.',
edit_title: 'Modifier lautorisation de lAPI',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Permesso',
placeholder_description:
"Il permesso si riferisce all'autorizzazione per accedere ad una risorsa (la chiamiamo risorsa API).",
edit: 'Permesso di modifica',
delete: 'Permesso di cancellazione',
remove: 'Permesso di rimozione',
updated: 'Permesso aggiornato.',
edit_title: 'Modifica permesso API',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: '権限',
placeholder_description:
'権限はリソースAPIリソースと呼んでいますにアクセスするための承認を指します。',
edit: '編集権限',
delete: '削除権限',
remove: '除去権限',
updated: '許可が更新されました。',
edit_title: 'APIの権限を編集',
};
export default Object.freeze(permissions);

View file

@ -6,6 +6,11 @@ const permissions = {
api_column: 'API',
placeholder_title: '권한',
placeholder_description: '권한은 리소스(API 리소스라고 함)에 액세스할 수 있는 권한을 의미해요.',
edit: '편집 권한',
delete: '삭제 권한',
remove: '제거 권한',
updated: '권한이 업데이트되었습니다.',
edit_title: 'API 권한 편집',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Uprawnienie',
placeholder_description:
'Uprawnienie odnosi się do autoryzacji dostępu do zasobu (nazywamy go zasobem API).',
edit: 'Uprawnienia do edycji',
delete: 'Uprawnienia do usunięcia',
remove: 'Uprawnienia do usunięcia',
updated: 'Uprawnienia zaktualizowane.',
edit_title: 'Edytuj uprawnienia API',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Permissão',
placeholder_description:
'Permissão refere-se à autorização para acessar um recurso (chamamos de recurso de API).',
edit: 'Permissão de edição',
delete: 'Permissão de exclusão',
remove: 'Permissão de remoção',
updated: 'Permissão atualizada.',
edit_title: 'Editar permissão de API',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Permissão',
placeholder_description:
'Permissão refere-se à autorização para acessar um recurso (que chamamos de recurso da API).',
edit: 'Permissão de edição',
delete: 'Permissão de eliminação',
remove: 'Permissão para remover',
updated: 'Permissão atualizada.',
edit_title: 'Editar permissão da API',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'Разрешение',
placeholder_description:
'Разрешение относится к авторизации доступа к ресурсу (мы называем это ресурсом API).',
edit: 'Разрешение на редактирование',
delete: 'Разрешение на удаление',
remove: 'Разрешение на удаление',
updated: 'Разрешение обновлено.',
edit_title: 'Редактировать разрешение API',
};
export default Object.freeze(permissions);

View file

@ -7,6 +7,11 @@ const permissions = {
placeholder_title: 'İzin',
placeholder_description:
'İzin, bir kaynağa erişmek için yetki verme durumunu ifade eder (biz buna API kaynağı diyoruz).',
edit: 'Düzenleme izni',
delete: 'Silme izni',
remove: 'Kaldırma izni',
updated: 'İzin güncellendi.',
edit_title: 'API iznini düzenle',
};
export default Object.freeze(permissions);

View file

@ -6,6 +6,11 @@ const permissions = {
api_column: 'API',
placeholder_title: '权限',
placeholder_description: '权限是指访问资源的授权(我们称其为 API 资源)。',
edit: '编辑权限',
delete: '删除权限',
remove: '移除权限',
updated: '权限已更新。',
edit_title: '编辑 API 权限',
};
export default Object.freeze(permissions);

View file

@ -6,6 +6,11 @@ const permissions = {
api_column: 'API',
placeholder_title: '權限',
placeholder_description: '權限是指訪問資源的授權(我們稱其為 API 資源)。',
edit: '編輯權限',
delete: '刪除權限',
remove: '移除權限',
updated: '權限已更新。',
edit_title: '編輯 API 權限',
};
export default Object.freeze(permissions);

View file

@ -6,6 +6,11 @@ const permissions = {
api_column: 'API',
placeholder_title: '權限',
placeholder_description: '權限是指訪問資源的授權(我們稱其為 API 資源)。',
edit: '編輯權限',
delete: '刪除權限',
remove: '移除權限',
updated: '權限已更新。',
edit_title: '編輯 API 權限',
};
export default Object.freeze(permissions);