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

feat(console): assign permissions to a role (#2882)

This commit is contained in:
Xiao Yijun 2023-01-10 14:55:37 +08:00 committed by GitHub
parent 1f293292b8
commit 751d6117c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 627 additions and 8 deletions

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5917 12.3583L14.125 8.825C14.2031 8.74753 14.2651 8.65536 14.3074 8.55381C14.3497 8.45226 14.3715 8.34334 14.3715 8.23333C14.3715 8.12332 14.3497 8.0144 14.3074 7.91285C14.2651 7.8113 14.2031 7.71913 14.125 7.64166C13.9689 7.48646 13.7577 7.39934 13.5375 7.39934C13.3174 7.39934 13.1062 7.48646 12.95 7.64166L10 10.5917L7.05002 7.64166C6.89389 7.48646 6.68268 7.39934 6.46252 7.39934C6.24237 7.39934 6.03116 7.48646 5.87502 7.64166C5.79779 7.71953 5.73668 7.81188 5.69521 7.91341C5.65374 8.01494 5.63272 8.12366 5.63336 8.23333C5.63272 8.343 5.65374 8.45172 5.69521 8.55325C5.73668 8.65478 5.79779 8.74713 5.87502 8.825L9.40836 12.3583C9.48582 12.4364 9.57799 12.4984 9.67954 12.5407C9.78109 12.583 9.89001 12.6048 10 12.6048C10.11 12.6048 10.219 12.583 10.3205 12.5407C10.4221 12.4984 10.5142 12.4364 10.5917 12.3583Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.3583 9.40833L8.82501 5.875C8.74754 5.79689 8.65538 5.7349 8.55383 5.69259C8.45228 5.65028 8.34336 5.6285 8.23335 5.6285C8.12334 5.6285 8.01442 5.65028 7.91287 5.69259C7.81132 5.7349 7.71915 5.79689 7.64168 5.875C7.48647 6.03114 7.39935 6.24235 7.39935 6.4625C7.39935 6.68266 7.48647 6.89387 7.64168 7.05L10.5917 10L7.64168 12.95C7.48647 13.1061 7.39935 13.3173 7.39935 13.5375C7.39935 13.7577 7.48647 13.9689 7.64168 14.125C7.71955 14.2022 7.81189 14.2633 7.91342 14.3048C8.01496 14.3463 8.12367 14.3673 8.23335 14.3667C8.34302 14.3673 8.45174 14.3463 8.55327 14.3048C8.6548 14.2633 8.74715 14.2022 8.82501 14.125L12.3583 10.5917C12.4365 10.5142 12.4984 10.422 12.5408 10.3205C12.5831 10.2189 12.6048 10.11 12.6048 10C12.6048 9.88999 12.5831 9.78107 12.5408 9.67952C12.4984 9.57797 12.4365 9.4858 12.3583 9.40833Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

View file

@ -0,0 +1,116 @@
import type { ResourceResponse, ScopeResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { ChangeEvent } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/images/search.svg';
import TextInput from '../TextInput';
import ResourceItem from './components/ResourceItem';
import * as styles from './index.module.scss';
import type { DetailedResourceResponse } from './types';
type Props = {
excludeScopeIds: string[];
selectedPermissions: ScopeResponse[];
onChange: (value: ScopeResponse[]) => void;
};
const SourcePermissionsBox = ({ excludeScopeIds, selectedPermissions, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data = [] } = useSWR<ResourceResponse[]>(`/api/resources?includeScopes=true`);
const [keyword, setKeyword] = useState('');
const handleSearchInput = (event: ChangeEvent<HTMLInputElement>) => {
setKeyword(event.target.value);
};
const isPermissionAdded = (scope: ScopeResponse) =>
selectedPermissions.findIndex(({ id }) => id === scope.id) >= 0;
const onSelectPermission = (scope: ScopeResponse) => {
const permissionAdded = isPermissionAdded(scope);
if (permissionAdded) {
onChange(selectedPermissions.filter(({ id }) => id !== scope.id));
return;
}
onChange([scope, ...selectedPermissions]);
};
const onSelectResource = ({ scopes }: DetailedResourceResponse) => {
const isAllSelected = scopes.every((scope) => isPermissionAdded(scope));
const scopesIds = new Set(scopes.map(({ id }) => id));
const basePermissions = selectedPermissions.filter(({ id }) => !scopesIds.has(id));
if (isAllSelected) {
onChange(basePermissions);
return;
}
onChange([...scopes, ...basePermissions]);
};
const getResourceSelectedPermissions = ({ scopes }: DetailedResourceResponse) =>
scopes.filter((scope) => selectedPermissions.findIndex(({ id }) => id === scope.id) >= 0);
const resources = data
.filter(({ scopes }) => scopes.some(({ id }) => !excludeScopeIds.includes(id)))
.map(({ scopes, ...resource }) => ({
...resource,
scopes: scopes
.filter(({ id }) => !excludeScopeIds.includes(id))
.map((scope) => ({
...scope,
resource,
})),
}));
const dataSource =
conditional(
keyword &&
resources
.filter(({ name, scopes }) => {
return name.includes(keyword) || scopes.some(({ name }) => name.includes(keyword));
})
.map(({ scopes, ...resource }) => ({
...resource,
scopes: scopes.filter(
({ name, resource }) => name.includes(keyword) || resource.name.includes(keyword)
),
}))
.filter(({ scopes }) => scopes.length > 0)
) ?? resources;
return (
<div className={styles.box}>
<div className={styles.top}>
<TextInput
className={styles.search}
icon={<Search className={styles.icon} />}
placeholder={t('general.search_placeholder')}
onChange={handleSearchInput}
/>
</div>
<div className={styles.content}>
{dataSource.map((resource) => (
<ResourceItem
key={resource.id}
resource={resource}
selectedPermissions={getResourceSelectedPermissions(resource)}
onSelectResource={onSelectResource}
onSelectPermission={onSelectPermission}
/>
))}
</div>
</div>
);
};
export default SourcePermissionsBox;

View file

@ -0,0 +1,38 @@
import type { ScopeResponse } from '@logto/schemas';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import TargetPermissionItem from './components/TargetPermissionItem';
import * as styles from './index.module.scss';
type Props = {
selectedScopes: ScopeResponse[];
onRemovePermission: (scope: ScopeResponse) => void;
};
const TargetPermissionsBox = ({ selectedScopes, onRemovePermission }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.box}>
<div className={classNames(styles.top, styles.added)}>
<span>{t('role_details.permission.added_text', { value: selectedScopes.length })}</span>
</div>
<div className={styles.content}>
{selectedScopes.map((scope) => {
return (
<TargetPermissionItem
key={scope.id}
scope={scope}
onDelete={() => {
onRemovePermission(scope);
}}
/>
);
})}
</div>
</div>
);
};
export default TargetPermissionsBox;

View file

@ -0,0 +1,38 @@
@use '@/scss/underscore' as _;
.resourceItem {
padding: 0 _.unit(4);
user-select: none;
.title {
display: flex;
align-items: center;
margin-top: _.unit(1.5);
.resource {
display: flex;
align-items: center;
cursor: pointer;
.caret {
margin-right: _.unit(2);
color: var(--color-text-secondary);
}
.name {
font: var(--font-label-2);
@include _.text-ellipsis;
}
.permissionInfo {
font: var(--font-body-medium);
color: var(--color-text-secondary);
margin-left: _.unit(2);
}
}
}
.invisible {
display: none;
}
}

View file

@ -0,0 +1,81 @@
import type { ScopeResponse } from '@logto/schemas';
import classNames from 'classnames';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import CaretExpanded from '@/assets/images/caret-expanded.svg';
import CaretFolded from '@/assets/images/caret-folded.svg';
import Checkbox from '@/components/Checkbox';
import { onKeyDownHandler } from '@/utilities/a11y';
import type { DetailedResourceResponse } from '../../types';
import SourcePermissionItem from '../SourcePermissionItem';
import * as styles from './index.module.scss';
type Props = {
resource: DetailedResourceResponse;
selectedPermissions: ScopeResponse[];
onSelectResource: (resource: DetailedResourceResponse) => void;
onSelectPermission: (scope: ScopeResponse) => void;
};
const ResourceItem = ({
resource,
selectedPermissions,
onSelectResource,
onSelectPermission,
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { name, scopes } = resource;
const selectedScopesCount = selectedPermissions.length;
const totalScopesCount = scopes.length;
const [isScopesInvisible, setIsScopesInvisible] = useState(true);
return (
<div className={styles.resourceItem}>
<div className={styles.title}>
<Checkbox
checked={selectedScopesCount === totalScopesCount}
indeterminate={selectedScopesCount > 0 && selectedScopesCount < totalScopesCount}
disabled={false}
onChange={() => {
onSelectResource(resource);
}}
/>
<div
role="button"
tabIndex={0}
className={styles.resource}
onKeyDown={onKeyDownHandler(() => {
setIsScopesInvisible(!isScopesInvisible);
})}
onClick={() => {
setIsScopesInvisible(!isScopesInvisible);
}}
>
{isScopesInvisible ? (
<CaretFolded className={styles.caret} />
) : (
<CaretExpanded className={styles.caret} />
)}
<div className={styles.name}>{name}</div>
<div className={styles.permissionInfo}>
({t('role_details.permission.api_permission_count', { value: scopes.length })})
</div>
</div>
</div>
<div className={classNames(isScopesInvisible && styles.invisible)}>
{scopes.map((scope) => (
<SourcePermissionItem
key={scope.id}
scope={scope}
isSelected={selectedPermissions.findIndex(({ id }) => scope.id === id) >= 0}
onSelectPermission={onSelectPermission}
/>
))}
</div>
</div>
);
};
export default ResourceItem;

View file

@ -0,0 +1,19 @@
@use '@/scss/underscore' as _;
.sourcePermissionItem {
display: flex;
align-items: center;
padding: _.unit(1.5) _.unit(7);
cursor: pointer;
.name {
padding: _.unit(1) _.unit(2);
border-radius: 6px;
background: var(--color-neutral-95);
@include _.text-ellipsis;
}
.icon {
color: var(--color-text-secondary);
}
}

View file

@ -0,0 +1,41 @@
import type { ScopeResponse } from '@logto/schemas';
import Checkbox from '@/components/Checkbox';
import { onKeyDownHandler } from '@/utilities/a11y';
import * as styles from './index.module.scss';
type Props = {
scope: ScopeResponse;
isSelected: boolean;
onSelectPermission: (scope: ScopeResponse) => void;
};
const SourcePermissionItem = ({ scope, isSelected, onSelectPermission }: Props) => {
const { name } = scope;
return (
<div
className={styles.sourcePermissionItem}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
onSelectPermission(scope);
})}
onClick={() => {
onSelectPermission(scope);
}}
>
<Checkbox
checked={isSelected}
disabled={false}
onChange={() => {
onSelectPermission(scope);
}}
/>
<div className={styles.name}>{name}</div>
</div>
);
};
export default SourcePermissionItem;

View file

@ -0,0 +1,31 @@
@use '@/scss/underscore' as _;
.targetPermissionItem {
display: flex;
align-items: center;
padding: _.unit(1.5) _.unit(4);
.title {
flex: 1 1 0;
display: flex;
align-items: center;
font: var(--font-body-medium);
overflow: hidden;
.name {
padding: _.unit(1) _.unit(2);
border-radius: 6px;
background: var(--color-neutral-95);
@include _.text-ellipsis;
}
.resourceName {
margin: 0 _.unit(2);
color: var(--color-text-secondary);
}
}
.icon {
color: var(--color-text-secondary);
}
}

View file

@ -0,0 +1,34 @@
import type { ScopeResponse } from '@logto/schemas';
import type { Key } from 'react';
import Close from '@/assets/images/close.svg';
import IconButton from '@/components/IconButton';
import * as styles from './index.module.scss';
export type Props = {
key: Key;
scope: ScopeResponse;
onDelete: () => void;
};
const TargetPermissionItem = ({ key, scope, onDelete }: Props) => {
const {
name,
resource: { name: resourceName },
} = scope;
return (
<div key={key} className={styles.targetPermissionItem}>
<div className={styles.title}>
<div className={styles.name}>{name}</div>
<div className={styles.resourceName}>{`(${resourceName})`}</div>
</div>
<IconButton size="small" iconClassName={styles.icon} onClick={onDelete}>
<Close />
</IconButton>
</div>
);
};
export default TargetPermissionItem;

View file

@ -0,0 +1,47 @@
@use '@/scss/underscore' as _;
.container {
border: 1px solid var(--color-border);
border-radius: 6px;
display: flex;
align-items: stretch;
overflow: hidden;
height: 360px;
.verticalBar {
@include _.vertical-bar;
}
.box {
flex: 1 1 0;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
.top {
height: 52px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
padding: 0 _.unit(4);
.search {
width: 100%;
}
&.added {
font: var(--font-label-large);
}
}
.content {
flex: 1 1 0;
overflow-y: auto;
}
.icon {
color: var(--color-text-secondary);
}
}
}

View file

@ -0,0 +1,30 @@
import type { ScopeResponse } from '@logto/schemas';
import SourcePermissionsBox from './SourcePermissionsBox';
import TargetPermissionsBox from './TargetPermissionsBox';
import * as styles from './index.module.scss';
type Props = {
value: ScopeResponse[];
onChange: (value: ScopeResponse[]) => void;
excludeScopeIds?: string[];
};
const RolePermissionsTransfer = ({ excludeScopeIds = [], value, onChange }: Props) => (
<div className={styles.container}>
<SourcePermissionsBox
excludeScopeIds={excludeScopeIds}
selectedPermissions={value}
onChange={onChange}
/>
<div className={styles.verticalBar} />
<TargetPermissionsBox
selectedScopes={value}
onRemovePermission={(scope) => {
onChange(value.filter(({ id }) => id !== scope.id));
}}
/>
</div>
);
export default RolePermissionsTransfer;

View file

@ -0,0 +1,5 @@
import type { ResourceResponse, ScopeResponse } from '@logto/schemas';
export type DetailedResourceResponse = Omit<ResourceResponse, 'scopes'> & {
scopes: ScopeResponse[];
};

View file

@ -0,0 +1,87 @@
import type { ScopeResponse } from '@logto/schemas';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import ModalLayout from '@/components/ModalLayout';
import RolePermissionsTransfer from '@/components/RolePermissionsTransfer';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
type Props = {
roleId: string;
excludeScopeIds: string[];
onClose: (success?: boolean) => void;
};
const AssignPermissionsModal = ({ roleId, excludeScopeIds, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isSubmitting, setIsSubmitting] = useState(false);
const [scopes, setScopes] = useState<ScopeResponse[]>([]);
const api = useApi();
const handleAssign = async () => {
if (isSubmitting || scopes.length === 0) {
return;
}
setIsSubmitting(true);
try {
await api.post(`/api/roles/${roleId}/scopes`, {
json: { scopeIds: scopes.map(({ id }) => id) },
});
toast.success(t('role_details.permission.permission_assigned'));
onClose(true);
} finally {
setIsSubmitting(false);
}
};
return (
<ReactModal
isOpen
shouldCloseOnEsc
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
}}
>
<ModalLayout
title="role_details.permission.assign_title"
subtitle="role_details.permission.assign_subtitle"
size="large"
footer={
<Button
isLoading={isSubmitting}
disabled={scopes.length === 0}
htmlType="submit"
title="role_details.permission.confirm_assign"
size="large"
type="primary"
onClick={handleAssign}
/>
}
onClose={onClose}
>
<FormField title="role_details.permission.assign_form_filed">
<RolePermissionsTransfer
value={scopes}
excludeScopeIds={excludeScopeIds}
onChange={(scopes) => {
setScopes(scopes);
}}
/>
</FormField>
</ModalLayout>
</ReactModal>
);
};
export default AssignPermissionsModal;

View file

@ -11,6 +11,7 @@ import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import type { RoleDetailsOutletContext } from '../types';
import AssignPermissionsModal from './components/AssignPermissionsModal';
const RolePermissions = () => {
const {
@ -27,6 +28,7 @@ const RolePermissions = () => {
const isLoading = !scopes && !error;
const [isAssignPermissionsModalOpen, setIsAssignPermissionsModalOpen] = useState(false);
const [scopeToBeDeleted, setScopeToBeDeleted] = useState<Scope>();
const [isDeleting, setIsDeleting] = useState(false);
@ -58,7 +60,7 @@ const RolePermissions = () => {
isLoading={isLoading}
createButtonTitle="role_details.permission.assign_button"
createHandler={() => {
// TODO @xiaoyijun Assign Permissions to Role
setIsAssignPermissionsModalOpen(true);
}}
deleteHandler={setScopeToBeDeleted}
errorMessage={error?.body?.message ?? error?.message}
@ -77,6 +79,18 @@ const RolePermissions = () => {
{t('role_details.permission.deletion_description')}
</ConfirmModal>
)}
{isAssignPermissionsModalOpen && (
<AssignPermissionsModal
excludeScopeIds={scopes?.map(({ id }) => id) ?? []}
roleId={roleId}
onClose={(success) => {
if (success) {
void mutate();
}
setIsAssignPermissionsModalOpen(false);
}}
/>
)}
</>
);
};

View file

@ -1,10 +1,11 @@
import type { Role } from '@logto/schemas';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { Role, ScopeResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { Controller, useForm } from 'react-hook-form';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import ModalLayout from '@/components/ModalLayout';
import RolePermissionsTransfer from '@/components/RolePermissionsTransfer';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
@ -12,11 +13,17 @@ export type Props = {
onClose: (createdRole?: Role) => void;
};
type CreateRoleFormData = Pick<Role, 'name' | 'description'>;
type CreateRoleFormData = Pick<Role, 'name' | 'description'> & {
scopes: ScopeResponse[];
};
type CreateRolePayload = Pick<Role, 'name' | 'description'> & {
scopeIds?: string[];
};
const CreateRoleForm = ({ onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
control,
handleSubmit,
register,
formState: { isSubmitting },
@ -24,12 +31,18 @@ const CreateRoleForm = ({ onClose }: Props) => {
const api = useApi();
const onSubmit = handleSubmit(async (formData) => {
const onSubmit = handleSubmit(async ({ name, description, scopes }) => {
if (isSubmitting) {
return;
}
const createdRole = await api.post('/api/roles', { json: formData }).json<Role>();
const payload: CreateRolePayload = {
name,
description,
scopeIds: conditional(scopes.length > 0 && scopes.map(({ id }) => id)),
};
const createdRole = await api.post('/api/roles', { json: payload }).json<Role>();
onClose(createdRole);
});
@ -37,6 +50,7 @@ const CreateRoleForm = ({ onClose }: Props) => {
<ModalLayout
title="roles.create_role_title"
subtitle="roles.create_role_description"
size="large"
footer={
<Button
isLoading={isSubmitting}
@ -60,6 +74,16 @@ const CreateRoleForm = ({ onClose }: Props) => {
<FormField title="roles.role_description">
<TextInput {...register('description')} />
</FormField>
<FormField title="roles.assign_permissions">
<Controller
control={control}
name="scopes"
defaultValue={[]}
render={({ field: { value, onChange } }) => (
<RolePermissionsTransfer value={value} onChange={onChange} />
)}
/>
</FormField>
</form>
</ModalLayout>
);

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles', // UNTRANSLATED
role_name: 'Role', // UNTRANSLATED
role_description: 'Description', // UNTRANSLATED
assign_permissions: 'Assign permissions', // UNTRANSLATED
create_role_title: 'Create a role', // UNTRANSLATED
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.', // UNTRANSLATED

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles',
role_name: 'Role',
role_description: 'Description',
assign_permissions: 'Assign permissions',
create_role_title: 'Create a role',
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.',

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles', // UNTRANSLATED
role_name: 'Role', // UNTRANSLATED
role_description: 'Description', // UNTRANSLATED
assign_permissions: 'Assign permissions', // UNTRANSLATED
create_role_title: 'Create a role', // UNTRANSLATED
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.', // UNTRANSLATED

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles', // UNTRANSLATED
role_name: 'Role', // UNTRANSLATED
role_description: 'Description', // UNTRANSLATED
assign_permissions: 'Assign permissions', // UNTRANSLATED
create_role_title: 'Create a role', // UNTRANSLATED
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.', // UNTRANSLATED

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles', // UNTRANSLATED
role_name: 'Role', // UNTRANSLATED
role_description: 'Description', // UNTRANSLATED
assign_permissions: 'Assign permissions', // UNTRANSLATED
create_role_title: 'Create a role', // UNTRANSLATED
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.', // UNTRANSLATED

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles', // UNTRANSLATED
role_name: 'Role', // UNTRANSLATED
role_description: 'Description', // UNTRANSLATED
assign_permissions: 'Assign permissions', // UNTRANSLATED
create_role_title: 'Create a role', // UNTRANSLATED
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.', // UNTRANSLATED

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles', // UNTRANSLATED
role_name: 'Role', // UNTRANSLATED
role_description: 'Description', // UNTRANSLATED
assign_permissions: 'Assign permissions', // UNTRANSLATED
create_role_title: 'Create a role', // UNTRANSLATED
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.', // UNTRANSLATED

View file

@ -5,6 +5,7 @@ const roles = {
create: 'Add Roles', // UNTRANSLATED
role_name: 'Role', // UNTRANSLATED
role_description: 'Description', // UNTRANSLATED
assign_permissions: 'Assign permissions', // UNTRANSLATED
create_role_title: 'Create a role', // UNTRANSLATED
create_role_description:
'Create and manage Roles for your applications. Roles contain collections of Permissions and can be assigned to Users.', // UNTRANSLATED