Added initial "Authorisation" tabs Settings and Resources (#1524)
This commit is contained in:
parent
ede8db53a0
commit
15baa43cfb
8 changed files with 559 additions and 3 deletions
|
@ -58,6 +58,8 @@ import { MapperList } from "../client-scopes/details/MapperList";
|
|||
import type { ProtocolMapperTypeRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/serverInfoRepesentation";
|
||||
import type ProtocolMapperRepresentation from "@keycloak/keycloak-admin-client/lib/defs/protocolMapperRepresentation";
|
||||
import { toMapper } from "./routes/Mapper";
|
||||
import { AuthorizationSettings } from "./authorization/Settings";
|
||||
import { AuthorizationResources } from "./authorization/Resources";
|
||||
|
||||
type ClientDetailHeaderProps = {
|
||||
onChange: (value: boolean) => void;
|
||||
|
@ -183,7 +185,8 @@ export default function ClientDetails() {
|
|||
const [changeAuthenticatorOpen, setChangeAuthenticatorOpen] = useState(false);
|
||||
const toggleChangeAuthenticator = () =>
|
||||
setChangeAuthenticatorOpen(!changeAuthenticatorOpen);
|
||||
const [activeTab2, setActiveTab2] = useState(30);
|
||||
const [clientScopeSubTab, setClientScopeSubTab] = useState(30);
|
||||
const [authorizationSubTab, setAuthorizationSubTab] = useState(40);
|
||||
|
||||
const form = useForm<ClientForm>({ shouldUnregister: false });
|
||||
const { clientId } = useParams<ClientParams>();
|
||||
|
@ -446,8 +449,8 @@ export default function ClientDetails() {
|
|||
title={<TabTitleText>{t("clientScopes")}</TabTitleText>}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab2}
|
||||
onSelect={(_, key) => setActiveTab2(key as number)}
|
||||
activeKey={clientScopeSubTab}
|
||||
onSelect={(_, key) => setClientScopeSubTab(key as number)}
|
||||
>
|
||||
<Tab
|
||||
id="setup"
|
||||
|
@ -472,6 +475,33 @@ export default function ClientDetails() {
|
|||
</Tabs>
|
||||
</Tab>
|
||||
)}
|
||||
{client!.serviceAccountsEnabled && (
|
||||
<Tab
|
||||
id="authorization"
|
||||
eventKey="authorization"
|
||||
title={<TabTitleText>{t("authorization")}</TabTitleText>}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={authorizationSubTab}
|
||||
onSelect={(_, key) => setAuthorizationSubTab(key as number)}
|
||||
>
|
||||
<Tab
|
||||
id="settings"
|
||||
eventKey={40}
|
||||
title={<TabTitleText>{t("settings")}</TabTitleText>}
|
||||
>
|
||||
<AuthorizationSettings clientId={clientId} />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="resources"
|
||||
eventKey={41}
|
||||
title={<TabTitleText>{t("resources")}</TabTitleText>}
|
||||
>
|
||||
<AuthorizationResources clientId={clientId} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Tab>
|
||||
)}
|
||||
{client!.serviceAccountsEnabled && (
|
||||
<Tab
|
||||
id="serviceAccount"
|
||||
|
|
90
src/clients/authorization/DetailCell.tsx
Normal file
90
src/clients/authorization/DetailCell.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DescriptionList,
|
||||
DescriptionListGroup,
|
||||
DescriptionListTerm,
|
||||
DescriptionListDescription,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
|
||||
import "./detail-cell.css";
|
||||
|
||||
type Scope = { id: string; name: string }[];
|
||||
|
||||
type DetailCellProps = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
uris?: string[];
|
||||
};
|
||||
|
||||
export const DetailCell = ({ id, clientId, uris }: DetailCellProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const adminClient = useAdminClient();
|
||||
const [scope, setScope] = useState<Scope>();
|
||||
const [permissions, setPermissions] =
|
||||
useState<ResourceServerRepresentation[]>();
|
||||
|
||||
useFetch(
|
||||
() =>
|
||||
Promise.all([
|
||||
adminClient.clients.listScopesByResource({
|
||||
id: clientId,
|
||||
resourceName: id,
|
||||
}),
|
||||
adminClient.clients.listPermissionsByResource({
|
||||
id: clientId,
|
||||
resourceId: id,
|
||||
}),
|
||||
]),
|
||||
([scopes, permissions]) => {
|
||||
setScope(scopes);
|
||||
setPermissions(permissions);
|
||||
},
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<DescriptionList isHorizontal className="keycloak_resource_details">
|
||||
{uris?.length !== 0 && (
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>{t("uris")}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{uris?.map((uri) => (
|
||||
<span key={uri} className="pf-u-pr-sm">
|
||||
{uri}
|
||||
</span>
|
||||
))}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
)}
|
||||
{scope?.length !== 0 && (
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>{t("scopes")}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{scope?.map((scope) => (
|
||||
<span key={scope.id} className="pf-u-pr-sm">
|
||||
{scope.name}
|
||||
</span>
|
||||
))}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
)}
|
||||
{permissions?.length !== 0 && (
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
{t("associatedPermissions")}
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{permissions?.map((permission) => (
|
||||
<span key={permission.id} className="pf-u-pr-sm">
|
||||
{permission.name}
|
||||
</span>
|
||||
))}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
)}
|
||||
</DescriptionList>
|
||||
);
|
||||
};
|
223
src/clients/authorization/Resources.tsx
Normal file
223
src/clients/authorization/Resources.tsx
Normal file
|
@ -0,0 +1,223 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
AlertVariant,
|
||||
Label,
|
||||
PageSection,
|
||||
Spinner,
|
||||
} from "@patternfly/react-core";
|
||||
import {
|
||||
ExpandableRowContent,
|
||||
TableComposable,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@patternfly/react-table";
|
||||
|
||||
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
|
||||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
import { PaginatingTableToolbar } from "../../components/table-toolbar/PaginatingTableToolbar";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { DetailCell } from "./DetailCell";
|
||||
|
||||
type ResourcesProps = {
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
type ExpandableResourceRepresentation = ResourceRepresentation & {
|
||||
isExpanded: boolean;
|
||||
};
|
||||
|
||||
export const AuthorizationResources = ({ clientId }: ResourcesProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const [resources, setResources] =
|
||||
useState<ExpandableResourceRepresentation[]>();
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRepresentation>();
|
||||
const [permissions, setPermission] =
|
||||
useState<ResourceServerRepresentation[]>();
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
const refresh = () => setKey(key + 1);
|
||||
|
||||
const [max, setMax] = useState(10);
|
||||
const [first, setFirst] = useState(0);
|
||||
|
||||
useFetch(
|
||||
() => {
|
||||
const params = {
|
||||
first,
|
||||
max,
|
||||
deep: false,
|
||||
};
|
||||
return adminClient.clients.listResources({
|
||||
...params,
|
||||
id: clientId,
|
||||
});
|
||||
},
|
||||
(resources) =>
|
||||
setResources(
|
||||
resources.map((resource) => ({ ...resource, isExpanded: false }))
|
||||
),
|
||||
[key]
|
||||
);
|
||||
|
||||
const UriRenderer = ({ row }: { row: ResourceRepresentation }) => (
|
||||
<>
|
||||
{row.uris?.[0]}{" "}
|
||||
{(row.uris?.length || 0) > 1 && (
|
||||
<Label color="blue">
|
||||
{t("common:more", { count: (row.uris?.length || 1) - 1 })}
|
||||
</Label>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const fetchPermissions = async (id: string) => {
|
||||
return adminClient.clients.listPermissionsByResource({
|
||||
id: clientId,
|
||||
resourceId: id,
|
||||
});
|
||||
};
|
||||
|
||||
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||
titleKey: "clients:deleteResource",
|
||||
children: (
|
||||
<>
|
||||
{t("deleteResourceConfirm")}
|
||||
{permissions?.length && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
isInline
|
||||
isPlain
|
||||
title={t("deleteResourceWarning")}
|
||||
className="pf-u-pt-lg"
|
||||
>
|
||||
<p className="pf-u-pt-xs">
|
||||
{permissions.map((permission) => (
|
||||
<strong key={permission.id} className="pf-u-pr-md">
|
||||
{permission.name}
|
||||
</strong>
|
||||
))}
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
continueButtonLabel: "clients:confirm",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await adminClient.clients.delResource({
|
||||
id: clientId,
|
||||
resourceId: selectedResource?._id!,
|
||||
});
|
||||
addAlert(t("resourceDeletedSuccess"), AlertVariant.success);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
addError("clients:resourceDeletedError", error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!resources) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection variant="light" className="pf-u-p-0">
|
||||
<DeleteConfirm />
|
||||
<PaginatingTableToolbar
|
||||
count={resources.length}
|
||||
first={first}
|
||||
max={max}
|
||||
onNextClick={setFirst}
|
||||
onPreviousClick={setFirst}
|
||||
onPerPageSelect={(first, max) => {
|
||||
setFirst(first);
|
||||
setMax(max);
|
||||
}}
|
||||
>
|
||||
<TableComposable aria-label={t("resources")} variant="compact">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th />
|
||||
<Th>{t("common:name")}</Th>
|
||||
<Th>{t("common:type")}</Th>
|
||||
<Th>{t("owner")}</Th>
|
||||
<Th>{t("uris")}</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
{resources.map((resource, rowIndex) => (
|
||||
<Tbody key={resource._id} isExpanded={resource.isExpanded}>
|
||||
<Tr>
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex,
|
||||
isExpanded: resource.isExpanded,
|
||||
onToggle: (_, rowIndex) => {
|
||||
const rows = resources.map((resource, index) =>
|
||||
index === rowIndex
|
||||
? { ...resource, isExpanded: !resource.isExpanded }
|
||||
: resource
|
||||
);
|
||||
setResources(rows);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Td>{resource.name}</Td>
|
||||
<Td>{resource.type}</Td>
|
||||
<Td>{resource.owner?.name}</Td>
|
||||
<Td>
|
||||
<UriRenderer row={resource} />
|
||||
</Td>
|
||||
<Td
|
||||
actions={{
|
||||
items: [
|
||||
{
|
||||
title: t("common:delete"),
|
||||
onClick: async () => {
|
||||
setSelectedResource(resource);
|
||||
setPermission(await fetchPermissions(resource._id!));
|
||||
toggleDeleteDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("createPermission"),
|
||||
className: "pf-m-link",
|
||||
isOutsideDropdown: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
></Td>
|
||||
</Tr>
|
||||
<Tr
|
||||
key={`child-${resource._id}`}
|
||||
isExpanded={resource.isExpanded}
|
||||
>
|
||||
<Td colSpan={5}>
|
||||
<ExpandableRowContent>
|
||||
{resource.isExpanded && (
|
||||
<DetailCell
|
||||
clientId={clientId}
|
||||
id={resource._id!}
|
||||
uris={resource.uris}
|
||||
/>
|
||||
)}
|
||||
</ExpandableRowContent>
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
))}
|
||||
</TableComposable>
|
||||
</PaginatingTableToolbar>
|
||||
</PageSection>
|
||||
);
|
||||
};
|
174
src/clients/authorization/Settings.tsx
Normal file
174
src/clients/authorization/Settings.tsx
Normal file
|
@ -0,0 +1,174 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
FormGroup,
|
||||
PageSection,
|
||||
Radio,
|
||||
Spinner,
|
||||
Switch,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
import type ResourceServerRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceServerRepresentation";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import { SaveReset } from "../advanced/SaveReset";
|
||||
|
||||
const POLICY_ENFORCEMENT_MODES = [
|
||||
"ENFORCING",
|
||||
"PERMISSIVE",
|
||||
"DISABLED",
|
||||
] as const;
|
||||
const DECISION_STRATEGY = ["UNANIMOUS", "AFFIRMATIVE"] as const;
|
||||
|
||||
export const AuthorizationSettings = ({ clientId }: { clientId: string }) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const [resource, setResource] = useState<ResourceServerRepresentation>();
|
||||
const { control, reset } = useForm<ResourceServerRepresentation>({
|
||||
shouldUnregister: false,
|
||||
});
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
|
||||
useFetch(
|
||||
() => adminClient.clients.getResourceServer({ id: clientId }),
|
||||
(resource) => {
|
||||
setResource(resource);
|
||||
reset(resource);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!resource) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection variant="light">
|
||||
<FormAccess role="manage-clients" isHorizontal>
|
||||
<FormGroup
|
||||
label={t("import")}
|
||||
fieldId="import"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:import"
|
||||
forLabel={t("import")}
|
||||
forID={t(`common:helpLabel`, { label: t("import") })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary">{t("import")}</Button>
|
||||
</FormGroup>
|
||||
<Divider />
|
||||
<FormGroup
|
||||
label={t("policyEnforcementMode")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:policyEnforcementMode"
|
||||
forLabel={t("policyEnforcementMode")}
|
||||
forID="policyEnforcementMode"
|
||||
/>
|
||||
}
|
||||
fieldId="policyEnforcementMode"
|
||||
hasNoPaddingTop
|
||||
>
|
||||
<Controller
|
||||
name="policyEnforcementMode"
|
||||
data-testid="policyEnforcementMode"
|
||||
defaultValue={DECISION_STRATEGY[0]}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<>
|
||||
{POLICY_ENFORCEMENT_MODES.map((mode) => (
|
||||
<Radio
|
||||
id={mode}
|
||||
key={mode}
|
||||
data-testid={mode}
|
||||
isChecked={value === mode}
|
||||
name="policyEnforcementMode"
|
||||
onChange={() => onChange(mode)}
|
||||
label={t(`policyEnforcementModes.${mode}`)}
|
||||
className="pf-u-mb-md"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("decisionStrategy")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="clients-help:decisionStrategy"
|
||||
forLabel={t("decisionStrategy")}
|
||||
forID="decisionStrategy"
|
||||
/>
|
||||
}
|
||||
fieldId="decisionStrategy"
|
||||
hasNoPaddingTop
|
||||
>
|
||||
<Controller
|
||||
name="decisionStrategy"
|
||||
data-testid="decisionStrategy"
|
||||
defaultValue={DECISION_STRATEGY[0]}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<>
|
||||
{DECISION_STRATEGY.map((strategy) => (
|
||||
<Radio
|
||||
id={strategy}
|
||||
key={strategy}
|
||||
data-testid={strategy}
|
||||
isChecked={value === strategy}
|
||||
name="decisionStrategy"
|
||||
onChange={() => onChange(strategy)}
|
||||
label={t(`decisionStrategies.${strategy}`)}
|
||||
className="pf-u-mb-md"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
hasNoPaddingTop
|
||||
label={t("allowRemoteResourceManagement")}
|
||||
fieldId="allowRemoteResourceManagement"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("allowRemoteResourceManagement")}
|
||||
forLabel={t("allowRemoteResourceManagement")}
|
||||
forID={"allowRemoteResourceManagement"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name="allowRemoteResourceManagement"
|
||||
data-testid="allowRemoteResourceManagement"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="allowRemoteResourceManagement"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<SaveReset
|
||||
name="settings"
|
||||
save={(): void => {
|
||||
// another PR
|
||||
}}
|
||||
reset={() => reset(resource)}
|
||||
/>
|
||||
</FormAccess>
|
||||
</PageSection>
|
||||
);
|
||||
};
|
3
src/clients/authorization/detail-cell.css
Normal file
3
src/clients/authorization/detail-cell.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.keycloak_resource_details {
|
||||
--pf-c-description-list--m-horizontal__term--width: 20ch;
|
||||
}
|
|
@ -154,5 +154,13 @@ export default {
|
|||
"Applicable only if 'Consent Required' is on for this client. If this switch is off, the consent screen will contain just the consents corresponding to configured client scopes. If on, there will be also one item on the consent screen about this client itself.",
|
||||
consentScreenText:
|
||||
"Applicable only if 'Display Client On Consent Screen' is on for this client. Contains the text which will be on the consent screen about permissions specific just for this client.",
|
||||
import:
|
||||
"Import a JSON file containing authorization settings for this resource server.",
|
||||
policyEnforcementMode:
|
||||
"The policy enforcement mode dictates how policies are enforced when evaluating authorization requests. 'Enforcing' means requests are denied by default even when there is no policy associated with a given resource. 'Permissive' means requests are allowed even when there is no policy associated with a given resource. 'Disabled' completely disables the evaluation of policies and allows access to any resource.",
|
||||
decisionStrategy:
|
||||
"The decision strategy dictates how permissions are evaluated and how a final decision is obtained. 'Affirmative' means that at least one permission must evaluate to a positive decision in order to grant access to a resource and its scopes. 'Unanimous' means that all permissions must evaluate to a positive decision in order for the final decision to be also positive.",
|
||||
allowRemoteResourceManagement:
|
||||
"Should resources be managed remotely by the resource server? If false, resources can be managed only from this admin console.",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -46,6 +46,33 @@ export default {
|
|||
type: "Assigned type",
|
||||
protocol: "Protocol",
|
||||
},
|
||||
authorization: "Authorization",
|
||||
settings: "Settings",
|
||||
policyEnforcementMode: "Policy enforcement mode",
|
||||
policyEnforcementModes: {
|
||||
ENFORCING: "Enforcing",
|
||||
PERMISSIVE: "Permissive",
|
||||
DISABLED: "Disabled",
|
||||
},
|
||||
decisionStrategy: "Decision strategy",
|
||||
decisionStrategies: {
|
||||
UNANIMOUS: "Unanimous",
|
||||
AFFIRMATIVE: "Affirmative",
|
||||
},
|
||||
resources: "Resources",
|
||||
owner: "Owner",
|
||||
uris: "URIs",
|
||||
scopes: "Scopes",
|
||||
createPermission: "Create permission",
|
||||
deleteResource: "Permanently delete resource?",
|
||||
deleteResourceConfirm:
|
||||
"If you delete this resource, some permissions will be affected.",
|
||||
deleteResourceWarning:
|
||||
"The permissions below will be removed when they are no longer used by other resources:",
|
||||
resourceDeletedSuccess: "The resource successfully deleted",
|
||||
resourceDeletedError: "Could not remove the resource {{error}}",
|
||||
associatedPermissions: "Associated permission",
|
||||
allowRemoteResourceManagement: "Remote resource management",
|
||||
assignedClientScope: "Assigned client scope",
|
||||
assignedType: "Assigned type",
|
||||
hideInheritedRoles: "Hide inherited roles",
|
||||
|
|
|
@ -52,6 +52,7 @@ export default {
|
|||
show: "Show",
|
||||
hide: "Hide",
|
||||
showRemaining: "Show ${remaining}",
|
||||
more: "{{count}} more",
|
||||
test: "Test",
|
||||
testConnection: "Test connection",
|
||||
name: "Name",
|
||||
|
|
Loading…
Reference in a new issue