mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
RolePickerDrawer: Replace RolePicker modal for drawer (#94801)
* Add RolePickerDrawer * Add RoiePickerBadges * Add RolePickerSelect * Replace RolePicker in OrgUsersTable * Replace RolePicker in ServiceAccountCreatePage * Add RolePickerDrawer test * Add tests * Add i18n texts * Update RolePickerBadges
This commit is contained in:
parent
15340a27ef
commit
06a0890c2e
@ -4688,9 +4688,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/search/utils.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/serviceaccounts/ServiceAccountPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
|
@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { OrgRole } from '@grafana/data';
|
||||
|
||||
import { RolePickerBadges } from './RolePickerBadges';
|
||||
|
||||
const props = {
|
||||
disabled: false,
|
||||
user: {
|
||||
login: 'admin',
|
||||
email: 'email@example.com',
|
||||
avatarUrl: 'avatarURL',
|
||||
lastSeenAt: 'lastSeenAt',
|
||||
lastSeenAtAge: 'lastSeenAtAge',
|
||||
name: 'administrator',
|
||||
orgId: 1,
|
||||
role: OrgRole.Admin,
|
||||
roles: [
|
||||
{
|
||||
uid: 'uid',
|
||||
name: 'admin',
|
||||
displayName: 'Admin',
|
||||
description: 'description',
|
||||
group: 'group',
|
||||
global: true,
|
||||
version: 1,
|
||||
created: 'created',
|
||||
updated: 'updated',
|
||||
},
|
||||
],
|
||||
userId: 1,
|
||||
isDisabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('RolePickerBadges', () => {
|
||||
it('should render', async () => {
|
||||
render(<RolePickerBadges {...props} />);
|
||||
|
||||
expect(screen.getByText(/\+1/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Admin/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Admin'));
|
||||
});
|
||||
});
|
@ -0,0 +1,63 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Badge, Stack } from '@grafana/ui';
|
||||
import { OrgUser } from 'app/types';
|
||||
|
||||
import { RolePickerDrawer } from './RolePickerDrawer';
|
||||
|
||||
export interface Props {
|
||||
disabled?: boolean;
|
||||
user: OrgUser;
|
||||
}
|
||||
|
||||
export const RolePickerBadges = ({ disabled, user }: Props) => {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
const { badge, badgeDisabled } = useStyles2(getStyles);
|
||||
const badgeStyle = disabled ? badgeDisabled : badge;
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: {
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
roles: user.roles,
|
||||
},
|
||||
});
|
||||
const { watch } = methods;
|
||||
|
||||
const drawerControl = () => {
|
||||
if (!disabled) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap={1}>
|
||||
<Badge className={badgeStyle} color="blue" onClick={drawerControl} text={watch('role')} />
|
||||
{user.roles && user.roles.length > 0 && (
|
||||
<Badge className={badgeStyle} color="blue" onClick={drawerControl} text={`+${user.roles.length}`} />
|
||||
)}
|
||||
</Stack>
|
||||
{isDrawerOpen && (
|
||||
<FormProvider {...methods}>
|
||||
<RolePickerDrawer onClose={() => setIsDrawerOpen(false)} />
|
||||
</FormProvider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
badge: css({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
badgeDisabled: css({
|
||||
cursor: 'not-allowed',
|
||||
}),
|
||||
};
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { RolePickerDrawer } from './RolePickerDrawer';
|
||||
|
||||
const props = {
|
||||
onClose: () => {},
|
||||
};
|
||||
|
||||
describe('RolePickerDrawer', () => {
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Wrapper = (props: WrapperProps) => {
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
name: 'service-account-name',
|
||||
},
|
||||
});
|
||||
return <FormProvider {...formMethods}>{props.children}</FormProvider>;
|
||||
};
|
||||
|
||||
it('should render', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<RolePickerDrawer {...props} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'service-account-name' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'documentation' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'None' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Viewer' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Editor' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Admin' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,52 @@
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { toOption } from '@grafana/data';
|
||||
import { Drawer, Field, RadioButtonGroup, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { OrgRole } from 'app/types';
|
||||
|
||||
const roleOptions = Object.keys(OrgRole).map(toOption);
|
||||
|
||||
const drawerSubtitle = (
|
||||
<Trans i18nKey="role-picker.title.description">
|
||||
Assign roles to users to ensure granular control over access to Grafana‘s features and resources. Find out
|
||||
more in our{' '}
|
||||
<TextLink
|
||||
external
|
||||
href="https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/#organization-roles"
|
||||
>
|
||||
documentation
|
||||
</TextLink>
|
||||
.
|
||||
</Trans>
|
||||
);
|
||||
|
||||
export interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RolePickerDrawer = ({ onClose }: Props) => {
|
||||
const methods = useFormContext();
|
||||
const { control, getValues, setValue } = methods;
|
||||
const [name, roles] = getValues(['name', 'roles']);
|
||||
return (
|
||||
<Drawer title={name} subtitle={drawerSubtitle} onClose={onClose}>
|
||||
<Field label={t('role-picker-drawer.basic-roles.label', 'Basic Roles')}>
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
render={({ field: { onChange, ref, ...fields } }) => (
|
||||
<RadioButtonGroup
|
||||
{...fields}
|
||||
options={roleOptions}
|
||||
onChange={(v) => {
|
||||
setValue('roleCollection', [v, ...roles]);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { RolePickerSelect } from './RolePickerSelect';
|
||||
|
||||
describe('RolePickerSelect', () => {
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Wrapper = (props: WrapperProps) => {
|
||||
const formMethods = useForm({});
|
||||
return <FormProvider {...formMethods}>{props.children}</FormProvider>;
|
||||
};
|
||||
|
||||
it('should render', async () => {
|
||||
const props = {};
|
||||
render(
|
||||
<Wrapper>
|
||||
<RolePickerSelect {...props} />
|
||||
</Wrapper>
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { toOption } from '@grafana/data';
|
||||
import { MultiSelect } from '@grafana/ui';
|
||||
|
||||
import { RolePickerDrawer } from './RolePickerDrawer';
|
||||
|
||||
export interface Props {}
|
||||
|
||||
export const RolePickerSelect = ({}: Props) => {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const { control } = useFormContext();
|
||||
|
||||
const toggleDrawer = () => setIsDrawerOpen(!isDrawerOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
name="role-collection"
|
||||
control={control}
|
||||
render={({ field: { ref, value, ...field } }) => (
|
||||
<MultiSelect
|
||||
{...field}
|
||||
onOpenMenu={toggleDrawer}
|
||||
onChange={() => {
|
||||
// TODO cannnot remove basic roles
|
||||
// TODO open drawer instead
|
||||
}}
|
||||
value={value?.map(toOption)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isDrawerOpen && <RolePickerDrawer onClose={toggleDrawer} />}
|
||||
</>
|
||||
);
|
||||
};
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { OrgRole } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@ -20,6 +21,7 @@ import {
|
||||
} from '@grafana/ui';
|
||||
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
|
||||
import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
|
||||
import { RolePickerBadges } from 'app/core/components/RolePickerDrawer/RolePickerBadges';
|
||||
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction, OrgUser, Role } from 'app/types';
|
||||
@ -127,6 +129,10 @@ export const OrgUsersTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (config.featureToggles.rolePickerDrawer) {
|
||||
return <RolePickerBadges disabled={basicRoleDisabled} user={original} />;
|
||||
}
|
||||
|
||||
return contextSrv.licensedAccessControlEnabled() ? (
|
||||
<UserRolePicker
|
||||
userId={original.userId}
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { config, getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { Button, Input, Field, FieldSet } from '@grafana/ui';
|
||||
import { t, Trans } from '@grafana/ui/src/utils/i18n';
|
||||
import { Form } from 'app/core/components/Form/Form';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
|
||||
import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
|
||||
import { RolePickerSelect } from 'app/core/components/RolePickerDrawer/RolePickerSelect';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction, OrgRole, Role, ServiceAccountCreateApiResponse, ServiceAccountDTO } from 'app/types';
|
||||
|
||||
@ -22,22 +25,37 @@ const createServiceAccount = async (sa: ServiceAccountDTO) => {
|
||||
const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) =>
|
||||
getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa);
|
||||
|
||||
const defaultServiceAccount = {
|
||||
id: 0,
|
||||
orgId: contextSrv.user.orgId,
|
||||
role: contextSrv.licensedAccessControlEnabled() ? OrgRole.None : OrgRole.Viewer,
|
||||
tokens: 0,
|
||||
name: '',
|
||||
login: '',
|
||||
isDisabled: false,
|
||||
createdAt: '',
|
||||
teams: [],
|
||||
};
|
||||
|
||||
export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
|
||||
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
|
||||
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
|
||||
|
||||
const currentOrgId = contextSrv.user.orgId;
|
||||
const [serviceAccount, setServiceAccount] = useState<ServiceAccountDTO>({
|
||||
id: 0,
|
||||
orgId: contextSrv.user.orgId,
|
||||
role: contextSrv.licensedAccessControlEnabled() ? OrgRole.None : OrgRole.Viewer,
|
||||
tokens: 0,
|
||||
name: '',
|
||||
login: '',
|
||||
isDisabled: false,
|
||||
createdAt: '',
|
||||
teams: [],
|
||||
const methods = useForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
role: defaultServiceAccount.role,
|
||||
roleCollection: [defaultServiceAccount.role],
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
const {
|
||||
formState: { errors },
|
||||
register,
|
||||
} = methods;
|
||||
|
||||
const currentOrgId = contextSrv.user.orgId;
|
||||
const [serviceAccount, setServiceAccount] = useState<ServiceAccountDTO>(defaultServiceAccount);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchOptions() {
|
||||
@ -47,7 +65,7 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
|
||||
setRoleOptions(options);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading options', e);
|
||||
console.error('Error loading options', e); // TODO: handle error
|
||||
}
|
||||
}
|
||||
if (contextSrv.licensedAccessControlEnabled()) {
|
||||
@ -79,7 +97,7 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
|
||||
await updateUserRoles(pendingRoles, newAccount.id, newAccount.orgId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error(e); // TODO: handle error
|
||||
}
|
||||
locationService.push(`/org/serviceaccounts/${response.id}`);
|
||||
},
|
||||
@ -99,44 +117,85 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Page navId="serviceaccounts" pageNav={{ text: 'Create service account' }}>
|
||||
<Page
|
||||
navId="serviceaccounts"
|
||||
pageNav={{ text: t('service-account-create-page.page-nav.label', 'Create service account') }}
|
||||
>
|
||||
<Page.Contents>
|
||||
<Form onSubmit={onSubmit} validateOn="onSubmit">
|
||||
{({ register, errors }) => {
|
||||
return (
|
||||
<>
|
||||
<FieldSet>
|
||||
<Field
|
||||
label="Display name"
|
||||
required
|
||||
invalid={!!errors.name}
|
||||
error={errors.name ? 'Display name is required' : undefined}
|
||||
>
|
||||
<Input id="display-name-input" {...register('name', { required: true })} autoFocus />
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
{contextSrv.licensedAccessControlEnabled() ? (
|
||||
<UserRolePicker
|
||||
apply
|
||||
userId={serviceAccount.id || 0}
|
||||
orgId={serviceAccount.orgId}
|
||||
basicRole={serviceAccount.role}
|
||||
onBasicRoleChange={onRoleChange}
|
||||
roleOptions={roleOptions}
|
||||
onApplyRoles={onPendingRolesUpdate}
|
||||
pendingRoles={pendingRoles}
|
||||
maxWidth="100%"
|
||||
/>
|
||||
) : (
|
||||
<OrgRolePicker aria-label="Role" value={serviceAccount.role} onChange={onRoleChange} />
|
||||
)}
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Button type="submit">Create</Button>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
{config.featureToggles.rolePickerDrawer && (
|
||||
<FormProvider {...methods}>
|
||||
<form>
|
||||
<FieldSet>
|
||||
<Field
|
||||
label={t('service-account-create-page.name.label', 'Display name')}
|
||||
required
|
||||
invalid={!!errors.name}
|
||||
error={
|
||||
errors.name
|
||||
? t('service-account-create-page.name.required-error', 'Display name is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input id="name" {...register('name', { required: true })} autoFocus />
|
||||
</Field>
|
||||
<Field label={t('service-account-create-page.role.label', 'Role')}>
|
||||
<RolePickerSelect />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Button type="submit">
|
||||
<Trans i18nKey="service-account-create-page.create.button">Create</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
{!config.featureToggles.rolePickerDrawer && (
|
||||
<Form onSubmit={onSubmit} validateOn="onSubmit">
|
||||
{({ register, errors }) => {
|
||||
return (
|
||||
<>
|
||||
<FieldSet>
|
||||
<Field
|
||||
label={t('service-account-create-page.name.label', 'Display name')}
|
||||
required
|
||||
invalid={!!errors.name}
|
||||
error={
|
||||
errors.name
|
||||
? t('service-account-create-page.name.required-error', 'Display name is required')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input id="display-name-input" {...register('name', { required: true })} autoFocus />
|
||||
</Field>
|
||||
<Field label={t('service-account-create-page.role.label', 'Role')}>
|
||||
{contextSrv.licensedAccessControlEnabled() ? (
|
||||
<UserRolePicker
|
||||
apply
|
||||
userId={serviceAccount.id || 0}
|
||||
orgId={serviceAccount.orgId}
|
||||
basicRole={serviceAccount.role}
|
||||
onBasicRoleChange={onRoleChange}
|
||||
roleOptions={roleOptions}
|
||||
onApplyRoles={onPendingRolesUpdate}
|
||||
pendingRoles={pendingRoles}
|
||||
maxWidth="100%"
|
||||
/>
|
||||
) : (
|
||||
<OrgRolePicker
|
||||
aria-label={t('service-account-create-page.role.label', 'Role')}
|
||||
value={serviceAccount.role}
|
||||
onChange={onRoleChange}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FieldSet>
|
||||
<Button type="submit">
|
||||
<Trans i18nKey="service-account-create-page.create.button">Create</Trans>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
@ -2455,6 +2455,16 @@
|
||||
},
|
||||
"dismissable-button": "Close"
|
||||
},
|
||||
"role-picker": {
|
||||
"title": {
|
||||
"description": "Assign roles to users to ensure granular control over access to Grafana‘s features and resources. Find out more in our <2>documentation</2>."
|
||||
}
|
||||
},
|
||||
"role-picker-drawer": {
|
||||
"basic-roles": {
|
||||
"label": "Basic Roles"
|
||||
}
|
||||
},
|
||||
"save-dashboards": {
|
||||
"name-exists": {
|
||||
"message-info": "A dashboard with the same name in the selected folder already exists, including recently deleted dashboards.",
|
||||
@ -2534,6 +2544,21 @@
|
||||
"selected-count": "Selected "
|
||||
}
|
||||
},
|
||||
"service-account-create-page": {
|
||||
"create": {
|
||||
"button": "Create"
|
||||
},
|
||||
"name": {
|
||||
"label": "Display name",
|
||||
"required-error": "Display name is required"
|
||||
},
|
||||
"page-nav": {
|
||||
"label": "Create service account"
|
||||
},
|
||||
"role": {
|
||||
"label": "Role"
|
||||
}
|
||||
},
|
||||
"service-accounts": {
|
||||
"empty-state": {
|
||||
"button-title": "Add service account",
|
||||
|
@ -2455,6 +2455,16 @@
|
||||
},
|
||||
"dismissable-button": "Cľőşę"
|
||||
},
|
||||
"role-picker": {
|
||||
"title": {
|
||||
"description": "Åşşįģʼn řőľęş ŧő ūşęřş ŧő ęʼnşūřę ģřäʼnūľäř čőʼnŧřőľ ővęř äččęşş ŧő Ğřäƒäʼnä&ľşqūő;ş ƒęäŧūřęş äʼnđ řęşőūřčęş. Fįʼnđ őūŧ mőřę įʼn őūř <2>đőčūmęʼnŧäŧįőʼn</2>."
|
||||
}
|
||||
},
|
||||
"role-picker-drawer": {
|
||||
"basic-roles": {
|
||||
"label": "ßäşįč Ŗőľęş"
|
||||
}
|
||||
},
|
||||
"save-dashboards": {
|
||||
"name-exists": {
|
||||
"message-info": "Å đäşĥþőäřđ ŵįŧĥ ŧĥę şämę ʼnämę įʼn ŧĥę şęľęčŧęđ ƒőľđęř äľřęäđy ęχįşŧş, įʼnčľūđįʼnģ řęčęʼnŧľy đęľęŧęđ đäşĥþőäřđş.",
|
||||
@ -2534,6 +2544,21 @@
|
||||
"selected-count": "Ŝęľęčŧęđ "
|
||||
}
|
||||
},
|
||||
"service-account-create-page": {
|
||||
"create": {
|
||||
"button": "Cřęäŧę"
|
||||
},
|
||||
"name": {
|
||||
"label": "Đįşpľäy ʼnämę",
|
||||
"required-error": "Đįşpľäy ʼnämę įş řęqūįřęđ"
|
||||
},
|
||||
"page-nav": {
|
||||
"label": "Cřęäŧę şęřvįčę äččőūʼnŧ"
|
||||
},
|
||||
"role": {
|
||||
"label": "Ŗőľę"
|
||||
}
|
||||
},
|
||||
"service-accounts": {
|
||||
"empty-state": {
|
||||
"button-title": "Åđđ şęřvįčę äččőūʼnŧ",
|
||||
|
Loading…
Reference in New Issue
Block a user