From 735332570fd819080fffc8b124177807297c0c64 Mon Sep 17 00:00:00 2001 From: Aaron Godin Date: Thu, 19 Sep 2024 14:58:11 -0500 Subject: [PATCH] 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 --- pkg/services/navtree/navtreeimpl/admin.go | 15 +++++++++ .../app/core/components/RolePicker/hooks.ts | 32 ++++++++++++++++++- .../app/core/components/Select/OrgPicker.tsx | 28 ++++++++++++---- .../app/core/utils/navBarItem-translations.ts | 2 ++ public/app/types/accessControl.ts | 4 +++ public/locales/en-US/grafana.json | 3 ++ public/locales/pseudo-LOCALE/grafana.json | 3 ++ 7 files changed, 79 insertions(+), 8 deletions(-) diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 7b16a0bb1ad..5a4d9cd2335 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -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", diff --git a/public/app/core/components/RolePicker/hooks.ts b/public/app/core/components/RolePicker/hooks.ts index f2e46152326..071f31eddaa 100644 --- a/public/app/core/components/RolePicker/hooks.ts +++ b/public/app/core/components/RolePicker/hooks.ts @@ -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; + 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({}); + + 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; +}; diff --git a/public/app/core/components/Select/OrgPicker.tsx b/public/app/core/components/Select/OrgPicker.tsx index 596d69b2d6d..c2737ba7757 100644 --- a/public/app/core/components/Select/OrgPicker.tsx +++ b/public/app/core/components/Select/OrgPicker.tsx @@ -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( + 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" /> diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 98673763803..eca9f15e11a 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -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': diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index c168d1c5272..7ea9ffa4a7b 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -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 { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 74905ac0577..d77f5848760 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1615,6 +1615,9 @@ "grafana-quaderno": { "title": "Grafana Quaderno" }, + "groupsync": { + "subtitle": "Manage mappings of Identity Provider groups to Grafana Roles" + }, "help": { "title": "Help" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 1107f3bdda2..d4dfb80e8c0 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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" },