feat: GroupSync extension UI (#91777)

* feat: supporting code for groupsync extension UI

* Add result of running i18n extraction

* Place the UI behind a feature toggle as well as the license feature

* Also add access checks to route loading of groupsync route with feature toggle

* Add access check on permissions to show External group sync in nav

* fix: New version of multiOrgRoleOptions hook

* Remove OSS route definition

* Apply feedback on nav title
This commit is contained in:
Aaron Godin
2024-09-19 14:58:11 -05:00
committed by GitHub
parent 534bfba7e3
commit 735332570f
7 changed files with 79 additions and 8 deletions
+15
View File
@@ -154,6 +154,21 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
})
}
if s.license.FeatureEnabled("groupsync") &&
s.features.IsEnabled(ctx, featuremgmt.FlagGroupAttributeSync) &&
hasAccess(ac.EvalAny(
ac.EvalPermission("groupsync.mappings:read"),
ac.EvalPermission("groupsync.mappings:write"),
)) {
accessNodeLinks = append(accessNodeLinks, &navtree.NavLink{
Text: "External group sync",
Id: "groupsync",
SubTitle: "Manage mappings of Identity Provider groups to Grafana Roles",
Icon: "",
Url: s.cfg.AppSubURL + "/admin/access/groupsync",
})
}
usersNode := &navtree.NavLink{
Text: "Users and access",
SubTitle: "Configure access for individual users, teams, and service accounts",
+31 -1
View File
@@ -1,11 +1,15 @@
import { difference } from 'lodash';
import { useState } from 'react';
import { useDeepCompareEffect } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { Role, AccessControlAction } from 'app/types';
import { fetchRoleOptions } from './api';
type MultiOrgRoleOptions = Record<number, Role[]>;
export const useRoleOptions = (organizationId: number) => {
const [orgId, setOrgId] = useState(organizationId);
@@ -18,3 +22,29 @@ export const useRoleOptions = (organizationId: number) => {
return [{ roleOptions: value }, setOrgId] as const;
};
export const useMultiOrgRoleOptions = (orgIDs: number[]): MultiOrgRoleOptions => {
const [orgRoleOptions, setOrgRoleOptions] = useState<MultiOrgRoleOptions>({});
useDeepCompareEffect(() => {
if (!contextSrv.licensedAccessControlEnabled() || !contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
return;
}
const currentOrgIDs = Object.keys(orgRoleOptions).map((o) => (typeof o === 'number' ? o : parseInt(o, 10)));
const newOrgIDs = difference(orgIDs, currentOrgIDs);
Promise.all(
newOrgIDs.map((orgID) => {
return fetchRoleOptions(orgID).then((roleOptions) => [orgID, roleOptions]);
})
).then((value) => {
setOrgRoleOptions({
...orgRoleOptions,
...Object.fromEntries(value),
});
});
}, [orgIDs]);
return orgRoleOptions;
};
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
@@ -14,9 +14,20 @@ export interface Props {
inputId?: string;
autoFocus?: boolean;
excludeOrgs?: UserOrg[];
defaultOrganization?: Organization;
}
export function OrgPicker({ onSelected, className, inputId, autoFocus, excludeOrgs }: Props) {
function orgToSelectItem(org: Organization): OrgSelectItem {
return {
value: org,
label: org.name,
};
}
export function OrgPicker({ onSelected, className, inputId, autoFocus, excludeOrgs, defaultOrganization }: Props) {
const [selected, setSelected] = useState<OrgSelectItem | undefined>(
defaultOrganization ? orgToSelectItem(defaultOrganization) : undefined
);
// For whatever reason the autoFocus prop doesn't seem to work
// with AsyncSelect, hence this workaround. Maybe fixed in a later version?
useEffect(() => {
@@ -27,13 +38,12 @@ export function OrgPicker({ onSelected, className, inputId, autoFocus, excludeOr
const [orgOptionsState, getOrgOptions] = useAsyncFn(async () => {
const orgs: Organization[] = await getBackendSrv().get('/api/orgs');
const allOrgs = orgs.map((org) => ({ value: { id: org.id, name: org.name }, label: org.name }));
const allOrgs = orgs.map(orgToSelectItem);
if (excludeOrgs) {
let idArray = excludeOrgs.map((anOrg) => anOrg.orgId);
const filteredOrgs = allOrgs.filter((item) => {
return !idArray.includes(item.value.id);
return allOrgs.filter((item) => {
return item.value !== undefined && !idArray.includes(item.value.id);
});
return filteredOrgs;
} else {
return allOrgs;
}
@@ -50,7 +60,11 @@ export function OrgPicker({ onSelected, className, inputId, autoFocus, excludeOr
const input = rawInput.toLowerCase();
return !!option.value?.name.toLowerCase().includes(input);
}}
onChange={onSelected}
onChange={(item) => {
onSelected(item);
setSelected(item);
}}
value={selected}
placeholder="Select organization"
noOptionsMessage="No organizations found"
/>
@@ -262,6 +262,8 @@ export function getNavSubTitle(navId: string | undefined) {
return t('nav.api-keys.subtitle', 'Manage and create API keys that are used to interact with Grafana HTTP APIs');
case 'serviceaccounts':
return t('nav.service-accounts.subtitle', 'Use service accounts to run automated workloads in Grafana');
case 'groupsync':
return t('nav.groupsync.subtitle', 'Manage mappings of Identity Provider groups to Grafana Roles');
case 'global-users':
return t('nav.global-users.subtitle', 'Manage users in Grafana');
case 'global-orgs':
+4
View File
@@ -137,6 +137,10 @@ export enum AccessControlAction {
// Settings
SettingsRead = 'settings:read',
SettingsWrite = 'settings:write',
// GroupSync
GroupSyncMappingsRead = 'groupsync.mappings:read',
GroupSyncMappingsWrite = 'groupsync.mappings:write',
}
export interface Role {
+3
View File
@@ -1615,6 +1615,9 @@
"grafana-quaderno": {
"title": "Grafana Quaderno"
},
"groupsync": {
"subtitle": "Manage mappings of Identity Provider groups to Grafana Roles"
},
"help": {
"title": "Help"
},
@@ -1615,6 +1615,9 @@
"grafana-quaderno": {
"title": "Ğřäƒäʼnä Qūäđęřʼnő"
},
"groupsync": {
"subtitle": "Mäʼnäģę mäppįʼnģş őƒ Ĩđęʼnŧįŧy Přővįđęř ģřőūpş ŧő Ğřäƒäʼnä Ŗőľęş"
},
"help": {
"title": "Ħęľp"
},