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 {
/>
+
+
`,
@@ -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);
+ });
+});