From 910d15f56c3cf6c4eb655aa96d834b5296ab322e Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 29 Sep 2026 16:46:19 +1000 Subject: [PATCH] feat(groups): configure default permissions and AD group sync Co-Authored-By: Claude Opus 5.5 (1M context) --- bun.lock | 4 +- package.json | 2 +- public/assets/locale/en-AU.json | 13 +- src/app/groups/group-about.component.ts | 63 ++++- .../groups/group-ad-groups-field.component.ts | 217 ++++++++++++++++++ .../groups/group-bulk-add-modal.component.ts | 4 +- src/app/groups/group-form.component.ts | 79 +++++++ src/app/groups/group-state.service.ts | 1 + src/app/groups/groups.utilities.ts | 75 +++++- .../group-ad-groups-field.component.spec.ts | 61 +++++ src/tests/groups/group-state.service.spec.ts | 11 +- src/tests/groups/groups.utilities.spec.ts | 88 +++++++ 12 files changed, 610 insertions(+), 8 deletions(-) create mode 100644 src/app/groups/group-ad-groups-field.component.ts create mode 100644 src/tests/groups/group-ad-groups-field.component.spec.ts create mode 100644 src/tests/groups/groups.utilities.spec.ts diff --git a/bun.lock b/bun.lock index 583c1d993..9ac703ccb 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ "@nx/web": "23.1.1", "@nx/workspace": "23.1.1", "@placeos/cloud-uploads": "^1.1.1", - "@placeos/ts-client": "^6.5.0", + "@placeos/ts-client": "^6.7.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.0.0", "@sentry/browser": "^10.25.0", @@ -876,7 +876,7 @@ "@placeos/cloud-uploads": ["@placeos/cloud-uploads@1.1.1", "", { "peerDependencies": { "rxjs": "^7.8.2", "ts-md5": "^2.0.1" } }, "sha512-krF2RiuX4u0MkHBhm+DkrxWa91pMHmiWs3M4tuFpFDEsnrpbvPVvVPDs1EwqyLib5D6d/vxS6an+TcMTGfA+tg=="], - "@placeos/ts-client": ["@placeos/ts-client@6.5.0", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-M6rvhk9So3vCFxlvqcvP7SpajFdrkikxICCFK323cQopx0iuApiIB1v4UNrQHl1L5lUvQzm0O09zFqpGViv+Cg=="], + "@placeos/ts-client": ["@placeos/ts-client@6.7.0", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-xkvzLDGCZ0RExV7mcLwqnCLrsYg4kPloy1NHuzJKI4pPa8ai3XfnXdt5NPzIHDsPjmtXEXYVwo5l/ejyj/BUOg=="], "@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="], diff --git a/package.json b/package.json index d9842c8a5..504f1f6a1 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@nx/web": "23.1.1", "@nx/workspace": "23.1.1", "@placeos/cloud-uploads": "^1.1.1", - "@placeos/ts-client": "^6.5.0", + "@placeos/ts-client": "^6.7.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.0.0", "@sentry/browser": "^10.25.0", diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 7b3f9c22b..c42e61879 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -810,7 +810,18 @@ "SWITCH_GROUP": "Switch group", "ALL_GROUPS": "All groups", "SEARCH_GROUPS": "Search groups", - "NO_MATCHING_GROUPS": "No matching groups" + "NO_MATCHING_GROUPS": "No matching groups", + "DEFAULT_PERMISSIONS": "Default permissions", + "DEFAULT_PERMISSIONS_HINT": "Permissions given to users added to this group without specific permissions.", + "AD_GROUPS": "AD group sync", + "AD_GROUPS_HINT": "Users in these AD groups are added to this group automatically with the set permissions.", + "AD_GROUPS_EMPTY": "No AD groups mapped to this group", + "AD_GROUP_SEARCH": "Search for AD groups...", + "AD_GROUP_ID": "AD group ID", + "AD_GROUP_NAME": "AD group name", + "AD_GROUP_ADD": "Add AD group", + "AD_GROUP_PERMISSIONS": "AD group permissions", + "AD_GROUP_REMOVE": "Remove AD group" }, "DOMAINS": { "SINGULAR": "Domain", diff --git a/src/app/groups/group-about.component.ts b/src/app/groups/group-about.component.ts index 3c0f1bee3..456b6cd1d 100644 --- a/src/app/groups/group-about.component.ts +++ b/src/app/groups/group-about.component.ts @@ -12,6 +12,7 @@ import { toSignal } from '../common/signals'; import { DateFromPipe } from '../ui/pipes/date-from.pipe'; import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; import { TranslatePipe } from '../ui/translate.pipe'; +import { groupPermissionLabels } from './group-permissions'; import { GroupStateService } from './group-state.service'; @Component({ @@ -21,7 +22,7 @@ import { GroupStateService } from './group-state.service';
@if (item()?.authority_id) {
@@ -73,6 +74,60 @@ import { GroupStateService } from './group-state.service'; }} }
+
+ {{ 'GROUPS.DEFAULT_PERMISSIONS' | translate }} +
+
+ @for ( + label of permissionLabels( + item()?.default_permissions || 0 + ); + track label + ) { + + {{ label | translate }} + + } @empty { + {{ + 'COMMON.NONE' | translate + }} + } +
+
+ {{ 'GROUPS.AD_GROUPS' | translate }} +
+
+ @for (ad_group of ad_groups(); track ad_group.id) { +
+ + {{ ad_group.name }} + + + {{ ad_group.id }} + + @for ( + label of permissionLabels( + ad_group.permissions + ); + track label + ) { + + {{ label | translate }} + + } +
+ } @empty { + {{ + 'GROUPS.AD_GROUPS_EMPTY' | translate + }} + } +
{{ 'GROUPS.CHILDREN_COUNT' | translate }}
@@ -143,6 +198,12 @@ export class GroupAboutComponent { }); public readonly parent = signal(null); public readonly authority = signal(null); + public readonly permissionLabels = groupPermissionLabels; + public readonly ad_groups = computed(() => + Object.entries(this.item()?.ad_group_mappings || {}).map( + ([id, [name, permissions]]) => ({ id, name, permissions }), + ), + ); public readonly created_at = computed( () => Date.parse(this.item()?.created_at || '') / 1000, ); diff --git a/src/app/groups/group-ad-groups-field.component.ts b/src/app/groups/group-ad-groups-field.component.ts new file mode 100644 index 000000000..bd993378a --- /dev/null +++ b/src/app/groups/group-ad-groups-field.component.ts @@ -0,0 +1,217 @@ +import { + Component, + computed, + inject, + input, + model, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { PlaceGroupAdMappings } from '@placeos/ts-client'; +import { waitForEvent } from '../common/signals'; +import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component'; +import { IconComponent } from '../ui/icon.component'; +import { TranslatePipe } from '../ui/translate.pipe'; +import { groupPermissionLabels } from './group-permissions'; +import { GroupPermissionsModalComponent } from './group-permissions-modal.component'; +import { + removeAdGroupMapping, + searchStaffGroups, + setAdGroupMapping, + StaffDirectoryGroup, +} from './groups.utilities'; + +/** + * Edits the AD group mappings of a group. + * New mappings get `default_permissions`. When `searchable` is set the AD + * groups are found with the staff API, otherwise the ID and name are typed in. + */ +@Component({ + selector: 'group-ad-groups-field', + template: ` +
+ @if (searchable()) { + + } @else if (searchable() === false) { +
+ + + + + + + +
+ } + @for (row of rows(); track row.id) { +
+
+
{{ row.name }}
+
+ {{ row.id }} +
+
+
+ @for ( + label of permissionLabels(row.permissions); + track label + ) { + + {{ label | translate }} + + } @empty { + {{ + 'COMMON.NONE' | translate + }} + } +
+ + +
+ } @empty { +

+ {{ 'GROUPS.AD_GROUPS_EMPTY' | translate }} +

+ } +
+ `, + styles: [``], + imports: [ + FormsModule, + IconComponent, + ItemSearchFieldComponent, + MatFormFieldModule, + MatInputModule, + MatRippleModule, + MatTooltipModule, + TranslatePipe, + ], +}) +export class GroupAdGroupsFieldComponent { + private _dialog = inject(MatDialog); + + public readonly mappings = model({}); + public readonly default_permissions = input(0); + /** Unset while the form checks for staff API search */ + public readonly searchable = input(); + + public readonly new_id = signal(''); + public readonly new_name = signal(''); + public readonly permissionLabels = groupPermissionLabels; + public readonly rows = computed(() => + Object.entries(this.mappings()) + .map(([id, [name, permissions]]) => ({ id, name, permissions })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + public readonly query_fn = (q: string) => searchStaffGroups(q); + public readonly exclude_fn = (group: StaffDirectoryGroup) => + !!this.mappings()[group.id.trim().toLowerCase()]; + + /** Maps a new AD group with the default permissions. Existing ones are kept */ + public addGroup(group: StaffDirectoryGroup | null) { + if (!group?.id || this.mappings()[group.id.trim().toLowerCase()]) { + return; + } + this.mappings.update((mappings) => + setAdGroupMapping( + mappings, + group.id, + group.name, + this.default_permissions(), + ), + ); + } + + public addManual() { + if (!this.new_id().trim()) return; + this.addGroup({ id: this.new_id(), name: this.new_name() }); + this.new_id.set(''); + this.new_name.set(''); + } + + public removeGroup(id: string) { + this.mappings.update((mappings) => removeAdGroupMapping(mappings, id)); + } + + public async editPermissions(row: { + id: string; + name: string; + permissions: number; + }) { + const result = await waitForEvent( + this._dialog + .open(GroupPermissionsModalComponent, { + data: { + title: 'GROUPS.AD_GROUP_PERMISSIONS', + permissions: row.permissions, + }, + }) + .afterClosed(), + ); + if (!result) return; + this.mappings.update((mappings) => + setAdGroupMapping(mappings, row.id, row.name, result.permissions), + ); + } +} diff --git a/src/app/groups/group-bulk-add-modal.component.ts b/src/app/groups/group-bulk-add-modal.component.ts index 59be9bef3..28122c047 100644 --- a/src/app/groups/group-bulk-add-modal.component.ts +++ b/src/app/groups/group-bulk-add-modal.component.ts @@ -34,6 +34,8 @@ export interface GroupBulkAddModalData { query_fn: (query: string) => Promise; exclude?: (item: T, search: string) => boolean; show_permissions?: boolean; + /** Initial permissions for the added items */ + permissions?: number; } export interface GroupBulkAddResult { @@ -229,7 +231,7 @@ export class GroupBulkAddModalComponent { public readonly selected = signal([]); public readonly search = signal(''); public readonly loading = signal(false); - public readonly permissions = signal(0); + public readonly permissions = signal(this._data.permissions ?? 0); public readonly tab = signal(0); public readonly title = this._data.title; public readonly placeholder = this._data.placeholder; diff --git a/src/app/groups/group-form.component.ts b/src/app/groups/group-form.component.ts index 0d064157c..6175b6b5a 100644 --- a/src/app/groups/group-form.component.ts +++ b/src/app/groups/group-form.component.ts @@ -20,6 +20,7 @@ import { addGroup, cleanObject, PlaceGroup, + PlaceGroupAdMappings, queryDomains, queryGroups, showGroup, @@ -38,10 +39,18 @@ import { DialogEvent, Identity } from '../common/types'; import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component'; import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component'; import { IconComponent } from '../ui/icon.component'; +import { SettingsToggleComponent } from '../ui/settings-toggle.component'; import { TranslatePipe } from '../ui/translate.pipe'; +import { GroupAdGroupsFieldComponent } from './group-ad-groups-field.component'; +import { + GROUP_PERMISSION_FLAGS, + hasGroupPermission, + setGroupPermission, +} from './group-permissions'; import { applyGroupFormSchema, generateGroupFormModel, + hasStaffGroupSearch, } from './groups.utilities'; @Component({ @@ -166,6 +175,52 @@ import { />
+
+ + {{ 'GROUPS.DEFAULT_PERMISSIONS' | translate }} + +

+ {{ 'GROUPS.DEFAULT_PERMISSIONS_HINT' | translate }} +

+
+ @for ( + permission of permission_flags; + track permission.key + ) { + + {{ permission.label | translate }} + + } +
+
+
+ + {{ 'GROUPS.AD_GROUPS' | translate }} + +

+ {{ 'GROUPS.AD_GROUPS_HINT' | translate }} +

+ +
`, @@ -181,6 +236,8 @@ import { TranslatePipe, FormField, FullscreenModalShellComponent, + GroupAdGroupsFieldComponent, + SettingsToggleComponent, ], }) export class GroupFormComponent extends AsyncHandler implements OnInit { @@ -209,6 +266,13 @@ export class GroupFormComponent extends AsyncHandler implements OnInit { () => this.formModel().subsystems || [], ); public readonly separators: number[] = [ENTER, COMMA]; + public readonly permission_flags = GROUP_PERMISSION_FLAGS; + public readonly hasPermission = hasGroupPermission; + /** Whether AD groups can be found with the staff API */ + public readonly staff_group_search = resource({ + params: () => this.formModel().authority_id, + loader: ({ params }) => hasStaffGroupSearch(params), + }); public readonly query_parent_groups = (_: string) => queryGroups({ q: _, limit: 20 }).then(({ data }) => data); public readonly exclude_parent_group = (group: PlaceGroup, __: string) => @@ -237,6 +301,21 @@ export class GroupFormComponent extends AsyncHandler implements OnInit { })); } + public setDefaultPermission(permission: number, enabled: boolean) { + this.formModel.update((value) => ({ + ...value, + default_permissions: setGroupPermission( + value.default_permissions, + permission, + enabled, + ), + })); + } + + public setAdGroupMappings(ad_group_mappings: PlaceGroupAdMappings) { + this.formModel.update((value) => ({ ...value, ad_group_mappings })); + } + public readonly addSubsystem = (event: MatChipInputEvent) => this.formModel.update((value) => ({ ...value, diff --git a/src/app/groups/group-state.service.ts b/src/app/groups/group-state.service.ts index 7696a8c8e..4d56f49fc 100644 --- a/src/app/groups/group-state.service.ts +++ b/src/app/groups/group-state.service.ts @@ -132,6 +132,7 @@ export class GroupStateService { placeholder: 'GROUPS.USER_SEARCH', empty_message: 'GROUPS.USERS_BULK_EMPTY', show_permissions: true, + permissions: this.active_item?.default_permissions, query_fn: (query: string) => queryUsers({ q: query, diff --git a/src/app/groups/groups.utilities.ts b/src/app/groups/groups.utilities.ts index 895e5c9b2..f5a247e8c 100644 --- a/src/app/groups/groups.utilities.ts +++ b/src/app/groups/groups.utilities.ts @@ -1,5 +1,11 @@ -import { authority, PlaceGroup } from '@placeos/ts-client'; import { required, SchemaFn } from '@angular/forms/signals'; +import { + authority, + get, + PlaceGroup, + PlaceGroupAdMappings, +} from '@placeos/ts-client'; +import type { PlaceTenant } from '../admin/staff-api.component'; export interface GroupFormModel { name: string; @@ -7,6 +13,16 @@ export interface GroupFormModel { parent_id: string; authority_id: string; subsystems: string[]; + default_permissions: number; + ad_group_mappings: PlaceGroupAdMappings; +} + +/** Directory group returned by the staff API group search */ +export interface StaffDirectoryGroup { + id: string; + name: string; + email?: string; + description?: string; } export function generateGroupFormModel(group?: PlaceGroup): GroupFormModel { @@ -16,9 +32,66 @@ export function generateGroupFormModel(group?: PlaceGroup): GroupFormModel { parent_id: group?.parent_id || '', authority_id: group?.authority_id || authority()?.id || '', subsystems: group?.subsystems || [], + default_permissions: group?.default_permissions || 0, + ad_group_mappings: { ...(group?.ad_group_mappings || {}) }, }; } export const applyGroupFormSchema: SchemaFn = (path) => { required(path.name); }; + +/** + * Returns a copy of `mappings` with the AD group added or replaced. + * IDs are trimmed and lower-cased to match how the API stores them. + */ +export function setAdGroupMapping( + mappings: PlaceGroupAdMappings, + id: string, + name: string, + permissions: number, +): PlaceGroupAdMappings { + const key = id.trim().toLowerCase(); + if (!key) return mappings; + return { ...mappings, [key]: [name.trim() || key, +permissions || 0] }; +} + +/** Returns a copy of `mappings` without the AD group `id` */ +export function removeAdGroupMapping( + mappings: PlaceGroupAdMappings, + id: string, +): PlaceGroupAdMappings { + const { [id]: _, ...rest } = mappings; + return rest; +} + +/** + * Whether the staff API can search directory groups for `authority_id`. + * The staff API uses the tenant of the current domain, so the authority + * must be the current one and have an Office 365 tenant. + * Only admins can list tenants, so other users test the search endpoint. + */ +export async function hasStaffGroupSearch(authority_id: string) { + const current = authority(); + if (!current?.id || current.id !== authority_id) return false; + try { + const tenants = (await get('/api/staff/v1/tenants')) as PlaceTenant[]; + return tenants.some( + (tenant) => + tenant.domain === current.domain && + tenant.platform === 'office365', + ); + } catch { + return searchStaffGroups('').then( + () => true, + () => false, + ); + } +} + +/** Searches directory groups of the current domain's staff API tenant */ +export async function searchStaffGroups(q: string) { + return (await get( + `/api/staff/v1/groups?q=${encodeURIComponent(q)}`, + )) as StaffDirectoryGroup[]; +} diff --git a/src/tests/groups/group-ad-groups-field.component.spec.ts b/src/tests/groups/group-ad-groups-field.component.spec.ts new file mode 100644 index 000000000..86b037bf3 --- /dev/null +++ b/src/tests/groups/group-ad-groups-field.component.spec.ts @@ -0,0 +1,61 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GroupAdGroupsFieldComponent } from '../../app/groups/group-ad-groups-field.component'; + +vi.mock('@placeos/ts-client', () => + vi.importActual('@placeos/ts-client/dist/index.es.js'), +); +vi.mock('../../app/groups/group-permissions-modal.component', () => ({ + GroupPermissionsModalComponent: class {}, +})); + +describe('GroupAdGroupsFieldComponent', () => { + const open = vi.fn(); + let fixture: ComponentFixture; + let component: GroupAdGroupsFieldComponent; + + beforeEach(() => { + open.mockReset(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: MatDialog, useValue: { open } }, + ], + }).overrideComponent(GroupAdGroupsFieldComponent, { + set: { template: '', imports: [] }, + }); + fixture = TestBed.createComponent(GroupAdGroupsFieldComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('default_permissions', 17); + fixture.componentRef.setInput('mappings', { + 'ad-1': ['Staff', 64], + }); + }); + + it('adds new AD groups with the default permissions', () => { + component.addGroup({ id: 'AD-2', name: 'Admins' }); + expect(component.mappings()).toEqual({ + 'ad-1': ['Staff', 64], + 'ad-2': ['Admins', 17], + }); + }); + + it('keeps custom permissions when an AD group is added again', () => { + component.addGroup({ id: ' AD-1 ', name: 'Staff' }); + expect(component.mappings()).toEqual({ 'ad-1': ['Staff', 64] }); + }); + + it('updates permissions of one AD group from the permissions modal', async () => { + open.mockReturnValue({ afterClosed: () => of({ permissions: 3 }) }); + await component.editPermissions(component.rows()[0]); + expect(component.mappings()).toEqual({ 'ad-1': ['Staff', 3] }); + }); + + it('removes an AD group', () => { + component.removeGroup('ad-1'); + expect(component.mappings()).toEqual({}); + }); +}); diff --git a/src/tests/groups/group-state.service.spec.ts b/src/tests/groups/group-state.service.spec.ts index d8922cc2e..6b0e88bb7 100644 --- a/src/tests/groups/group-state.service.spec.ts +++ b/src/tests/groups/group-state.service.spec.ts @@ -58,7 +58,11 @@ vi.mock('../../app/groups/group-permissions-modal.component', () => ({ })); describe('group membership actions', () => { - const group = new PlaceGroup({ id: 'group-1', authority_id: 'domain-1' }); + const group = new PlaceGroup({ + id: 'group-1', + authority_id: 'domain-1', + default_permissions: 5, + }); const user = new PlaceGroupUser({ group_id: 'group-1', user_id: 'user-1', @@ -257,6 +261,11 @@ describe('group membership actions', () => { expect(service.loading()).toBe(false); }); + it('starts bulk user permissions at the group default permissions', async () => { + await service.bulkAddUsers(); + expect(mocks.open.mock.calls[0][1].data.permissions).toBe(5); + }); + it('does not add memberships after a bulk dialog is cancelled', async () => { await service.bulkAddUsers(); await service.bulkAddZones(); diff --git a/src/tests/groups/groups.utilities.spec.ts b/src/tests/groups/groups.utilities.spec.ts new file mode 100644 index 000000000..1d76893d5 --- /dev/null +++ b/src/tests/groups/groups.utilities.spec.ts @@ -0,0 +1,88 @@ +import { PlaceGroup } from '@placeos/ts-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + generateGroupFormModel, + hasStaffGroupSearch, + removeAdGroupMapping, + setAdGroupMapping, +} from '../../app/groups/groups.utilities'; + +const mocks = vi.hoisted(() => ({ authority: vi.fn(), get: vi.fn() })); +vi.mock('@placeos/ts-client', async () => ({ + ...(await vi.importActual( + '@placeos/ts-client/dist/index.es.js', + )), + ...mocks, +})); + +describe('group AD group mappings', () => { + it('stores AD group IDs trimmed and lower-cased', () => { + expect(setAdGroupMapping({}, ' AD-1 ', ' Staff ', 5)).toEqual({ + 'ad-1': ['Staff', 5], + }); + }); + + it('uses the ID as the name when no name is given', () => { + expect(setAdGroupMapping({}, 'ad-1', '', 1)).toEqual({ + 'ad-1': ['ad-1', 1], + }); + }); + + it('ignores blank IDs', () => { + const mappings = { 'ad-1': ['Staff', 1] as [string, number] }; + expect(setAdGroupMapping(mappings, ' ', 'Blank', 1)).toBe(mappings); + }); + + it('removes a mapping without changing the others', () => { + expect( + removeAdGroupMapping( + { 'ad-1': ['Staff', 1], 'ad-2': ['Admins', 64] }, + 'ad-1', + ), + ).toEqual({ 'ad-2': ['Admins', 64] }); + }); + + it('copies mappings into the form model', () => { + const ad_group_mappings = { 'ad-1': ['Staff', 1] as [string, number] }; + const model = generateGroupFormModel( + new PlaceGroup({ default_permissions: 3, ad_group_mappings }), + ); + expect(model.default_permissions).toBe(3); + expect(model.ad_group_mappings).toEqual(ad_group_mappings); + expect(model.ad_group_mappings).not.toBe(ad_group_mappings); + }); +}); + +describe('hasStaffGroupSearch', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.authority.mockReturnValue({ id: 'auth-1', domain: 'here.com' }); + }); + + it('needs an Office 365 tenant for the current domain', async () => { + mocks.get.mockResolvedValue([ + { domain: 'other.com', platform: 'office365' }, + { domain: 'here.com', platform: 'google' }, + ]); + expect(await hasStaffGroupSearch('auth-1')).toBe(false); + mocks.get.mockResolvedValue([ + { domain: 'here.com', platform: 'office365' }, + ]); + expect(await hasStaffGroupSearch('auth-1')).toBe(true); + }); + + it('is off for other authorities', async () => { + expect(await hasStaffGroupSearch('auth-2')).toBe(false); + expect(mocks.get).not.toHaveBeenCalled(); + }); + + it('tests the group search when tenants cannot be listed', async () => { + mocks.get + .mockRejectedValueOnce(new Error('Forbidden')) + .mockResolvedValueOnce([]); + expect(await hasStaffGroupSearch('auth-1')).toBe(true); + expect(mocks.get).toHaveBeenLastCalledWith('/api/staff/v1/groups?q='); + mocks.get.mockRejectedValue(new Error('Not Implemented')); + expect(await hasStaffGroupSearch('auth-1')).toBe(false); + }); +});