diff --git a/bun.lock b/bun.lock
index cd2857af4..b6e16bb51 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"@angular/platform-browser": "22.0.0",
"@angular/router": "22.0.0",
"@angular/service-worker": "22.0.0",
+ "@placeos/ts-client": "github:PlaceOS/ts-client#b539b2b2aae36d40d739977ecee6589bb4d903c8",
"postcss": "^8.5.6",
"shiki": "^4.0.2",
"tailwindcss": "^4.1.17",
@@ -43,7 +44,7 @@
"@nx/web": "23.1.1",
"@nx/workspace": "23.1.1",
"@placeos/cloud-uploads": "^1.1.3",
- "@placeos/ts-client": "^6.7.0",
+ "@placeos/ts-client": "github:PlaceOS/ts-client#b539b2b2aae36d40d739977ecee6589bb4d903c8",
"@playwright/test": "^1.36.0",
"@schematics/angular": "22.0.0",
"@sentry/browser": "^10.25.0",
@@ -876,7 +877,7 @@
"@placeos/cloud-uploads": ["@placeos/cloud-uploads@1.1.3", "", { "peerDependencies": { "rxjs": "^7.8.2", "ts-md5": "^2.0.1" } }, "sha512-GRHSzTfvSlMQG+98sxzfWhHuWXloFtlc1lg3GA/lTlAXBzIhJUgfkkox+kY8w6Wdwrh4X2WsxjAoAaO0c/+KDg=="],
- "@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=="],
+ "@placeos/ts-client": ["@placeos/ts-client@github:PlaceOS/ts-client#b539b2b", { "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" } }, "PlaceOS-ts-client-b539b2b", "sha512-yBABiF0Ny6I3rPcveZsLTlLQEY3mOcUyrnZ31SKvmzqZOG4u/esMhw02ZMPZODEGpyl46uRVP2DAMyYjbt6zRQ=="],
"@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 b22108693..22d22b8fa 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,8 @@
"postcss": "^8.5.6",
"shiki": "^4.0.2",
"tailwindcss": "^4.1.17",
- "zone.js": "~0.15.0"
+ "zone.js": "~0.15.0",
+ "@placeos/ts-client": "github:PlaceOS/ts-client#b539b2b2aae36d40d739977ecee6589bb4d903c8"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "2.6.4",
@@ -52,7 +53,7 @@
"@nx/web": "23.1.1",
"@nx/workspace": "23.1.1",
"@placeos/cloud-uploads": "^1.1.3",
- "@placeos/ts-client": "^6.7.0",
+ "@placeos/ts-client": "github:PlaceOS/ts-client#b539b2b2aae36d40d739977ecee6589bb4d903c8",
"@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 531cf1b6a..f824f8915 100644
--- a/public/assets/locale/en-AU.json
+++ b/public/assets/locale/en-AU.json
@@ -235,7 +235,11 @@
"DATE_FROM_YEARS_AGO_1": "{{ years }} year ago",
"DATE_FROM_YEARS_AGO_N": "{{ years }} years ago",
"CRON_CUSTOM": "Custom",
- "CONFIG": "Configuration"
+ "CONFIG": "Configuration",
+ "ORGANISATION": "Organisation",
+ "ORGANISATIONS": "Organisations",
+ "PARTNER": "Partner",
+ "PARTNERS": "Partners"
},
"SYSTEMS": {
"SINGULAR": "System",
@@ -701,7 +705,7 @@
"ROLE_NONE": "None",
"FIELD_GROUPS": "User Groups",
"FIELD_DEPARTMENT": "Department",
- "AUTHORITY_ID": "Authority",
+ "AUTHORITY_ID": "Domain",
"NO_GROUPS": "No user groups",
"GROUPS_EMPTY": "No groups associated with this user",
"GROUPS_BULK": "Bulk add groups",
@@ -760,7 +764,7 @@
"TAB_USERS": "Users",
"TAB_ZONES": "Zones",
"NAME_REQUIRED": "A group name is required",
- "AUTHORITY_ID": "Authority",
+ "AUTHORITY_ID": "Domain",
"AUTHORITY_SELECT": "Select domain",
"PARENT_ID": "Parent Group",
"PARENT_SEARCH": "Search for parent group...",
@@ -1283,7 +1287,8 @@
"AI_USAGE_JOBS": "Requests",
"AI_USAGE_LOAD_FAILED": "AI usage could not be loaded.",
"AI_USAGE_MADE": "Images made",
- "TAB_SIGNAGE_AI": "Signage AI"
+ "TAB_SIGNAGE_AI": "Signage AI",
+ "TAB_ORGANISATIONS": "Organisations"
},
"CASCADE": {
"RESOLVING": "Working out what would be removed...",
@@ -1328,5 +1333,63 @@
"RECEIPT_COPY": "Copy list",
"RECEIPT_COPIED": "Copied {{ count }} rows to the clipboard.",
"RECEIPT_COPIED_1": "Copied {{ count }} row to the clipboard."
+ },
+ "ORGANISATIONS": {
+ "SINGULAR": "Organisation",
+ "PLURAL": "Organisations",
+ "SWITCH": "Switch organisation",
+ "SEARCH": "Search organisations",
+ "ALL": "All organisations",
+ "NO_MATCHING": "No matching organisations",
+ "UNOWNED": "No organisation (cluster only)",
+ "HEADER": "Organisations",
+ "PARTNERS_HEADER": "Partners",
+ "ADD": "Add organisation",
+ "EDIT": "Edit organisation",
+ "NEW": "New organisation",
+ "REMOVE": "Remove organisation",
+ "REMOVE_MSG": "Remove the organisation {{ name }}? This fails while any domain or estate still belongs to it.",
+ "SAVING": "Saving organisation...",
+ "SAVE_SUCCESS": "Organisation saved",
+ "SAVE_ERROR": "Failed to save organisation. Error: {{ error }}",
+ "REMOVE_SUCCESS": "Organisation removed",
+ "REMOVE_ERROR": "Failed to remove organisation. Error: {{ error }}",
+ "NAME_REQUIRED": "Organisation name is required",
+ "FIELD_PARTNER": "Partner",
+ "SELF_MANAGED": "Self managed (no partner)",
+ "FIELD_PAYER": "Invoiced to",
+ "PAYER_PARTNER": "Partner",
+ "PAYER_ORGANISATION": "Organisation",
+ "FIELD_PARTNER_STAFF": "Partner staff organisation",
+ "PARTNER_STAFF_HINT": "Admins of this organisation reach every organisation under the partner",
+ "FIELD_DOMAINS": "Domains",
+ "CLAIM": "Claim unowned zones",
+ "CLAIM_TITLE": "Claim unowned zones for {{ name }}",
+ "CLAIM_DESC": "Root zones that no organisation owns. The zones chosen, their children, and the systems and modules inside them become this organisation's.",
+ "CLAIM_EMPTY": "Every root zone already has an owner",
+ "CLAIM_SUCCESS": "Claimed {{ zones }} zones, {{ systems }} systems and {{ modules }} modules",
+ "CLAIM_ERROR": "Failed to claim zones. Error: {{ error }}",
+ "CLAIM_ACTION": "Claim",
+ "PARTNER_ADD": "Add partner",
+ "PARTNER_EDIT": "Edit partner",
+ "PARTNER_NEW": "New partner",
+ "PARTNER_REMOVE": "Remove partner",
+ "PARTNER_REMOVE_MSG": "Remove the partner {{ name }}? This fails while any organisation still belongs to it.",
+ "PARTNER_SAVING": "Saving partner...",
+ "PARTNER_SAVE_SUCCESS": "Partner saved",
+ "PARTNER_SAVE_ERROR": "Failed to save partner. Error: {{ error }}",
+ "PARTNER_REMOVE_SUCCESS": "Partner removed",
+ "PARTNER_REMOVE_ERROR": "Failed to remove partner. Error: {{ error }}",
+ "PARTNER_NAME_REQUIRED": "Partner name is required",
+ "MANAGEMENT": "Management partner",
+ "FIELD_MANAGEMENT": "Management",
+ "EMPTY": "No organisations in reach",
+ "PARTNERS_EMPTY": "No partners in reach",
+ "ENFORCING": "Enforcing",
+ "LOG_ONLY": "Log only",
+ "REACH": "Your reach",
+ "REACH_CLUSTER": "Cluster: every organisation",
+ "REACH_PARTNER": "Partner: every organisation under {{ name }}",
+ "REACH_ORGANISATION": "Organisation: {{ name }}"
}
}
diff --git a/public/assets/locale/en-GB.json b/public/assets/locale/en-GB.json
index 0d0cca2ad..1dda6ec1c 100644
--- a/public/assets/locale/en-GB.json
+++ b/public/assets/locale/en-GB.json
@@ -204,7 +204,11 @@
"DATE_FROM_IN_MONTHS": "In {{ months }} month(s)",
"DATE_FROM_MONTHS_AGO": "{{ months }} month(s) ago",
"DATE_FROM_IN_YEAS": "In {{ years }} year(s)",
- "DATE_FROM_YEARS_AGO": "{{ years }} year(s) ago"
+ "DATE_FROM_YEARS_AGO": "{{ years }} year(s) ago",
+ "ORGANISATION": "Organisation",
+ "ORGANISATIONS": "Organisations",
+ "PARTNER": "Partner",
+ "PARTNERS": "Partners"
},
"SYSTEMS": {
"SINGULAR": "System",
@@ -979,7 +983,8 @@
"UPLOADS_LIB_REMOVE_LOADING": "Removing upload...",
"UPLOADS_LIB_FIELD_TYPE": "File Type",
"UPLOADS_LIB_FIELD_SIZE": "Size",
- "UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain"
+ "UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain",
+ "TAB_ORGANISATIONS": "Organisations"
},
"CASCADE": {
"RESOLVING": "Working out what would be removed...",
@@ -1030,5 +1035,63 @@
"ALL_GROUPS": "All groups",
"SEARCH_GROUPS": "Search groups",
"NO_MATCHING_GROUPS": "No matching groups"
+ },
+ "ORGANISATIONS": {
+ "SINGULAR": "Organisation",
+ "PLURAL": "Organisations",
+ "SWITCH": "Switch organisation",
+ "SEARCH": "Search organisations",
+ "ALL": "All organisations",
+ "NO_MATCHING": "No matching organisations",
+ "UNOWNED": "No organisation (cluster only)",
+ "HEADER": "Organisations",
+ "PARTNERS_HEADER": "Partners",
+ "ADD": "Add organisation",
+ "EDIT": "Edit organisation",
+ "NEW": "New organisation",
+ "REMOVE": "Remove organisation",
+ "REMOVE_MSG": "Remove the organisation {{ name }}? This fails while any domain or estate still belongs to it.",
+ "SAVING": "Saving organisation...",
+ "SAVE_SUCCESS": "Organisation saved",
+ "SAVE_ERROR": "Failed to save organisation. Error: {{ error }}",
+ "REMOVE_SUCCESS": "Organisation removed",
+ "REMOVE_ERROR": "Failed to remove organisation. Error: {{ error }}",
+ "NAME_REQUIRED": "Organisation name is required",
+ "FIELD_PARTNER": "Partner",
+ "SELF_MANAGED": "Self managed (no partner)",
+ "FIELD_PAYER": "Invoiced to",
+ "PAYER_PARTNER": "Partner",
+ "PAYER_ORGANISATION": "Organisation",
+ "FIELD_PARTNER_STAFF": "Partner staff organisation",
+ "PARTNER_STAFF_HINT": "Admins of this organisation reach every organisation under the partner",
+ "FIELD_DOMAINS": "Domains",
+ "CLAIM": "Claim unowned zones",
+ "CLAIM_TITLE": "Claim unowned zones for {{ name }}",
+ "CLAIM_DESC": "Root zones that no organisation owns. The zones chosen, their children, and the systems and modules inside them become this organisation's.",
+ "CLAIM_EMPTY": "Every root zone already has an owner",
+ "CLAIM_SUCCESS": "Claimed {{ zones }} zones, {{ systems }} systems and {{ modules }} modules",
+ "CLAIM_ERROR": "Failed to claim zones. Error: {{ error }}",
+ "CLAIM_ACTION": "Claim",
+ "PARTNER_ADD": "Add partner",
+ "PARTNER_EDIT": "Edit partner",
+ "PARTNER_NEW": "New partner",
+ "PARTNER_REMOVE": "Remove partner",
+ "PARTNER_REMOVE_MSG": "Remove the partner {{ name }}? This fails while any organisation still belongs to it.",
+ "PARTNER_SAVING": "Saving partner...",
+ "PARTNER_SAVE_SUCCESS": "Partner saved",
+ "PARTNER_SAVE_ERROR": "Failed to save partner. Error: {{ error }}",
+ "PARTNER_REMOVE_SUCCESS": "Partner removed",
+ "PARTNER_REMOVE_ERROR": "Failed to remove partner. Error: {{ error }}",
+ "PARTNER_NAME_REQUIRED": "Partner name is required",
+ "MANAGEMENT": "Management partner",
+ "FIELD_MANAGEMENT": "Management",
+ "EMPTY": "No organisations in reach",
+ "PARTNERS_EMPTY": "No partners in reach",
+ "ENFORCING": "Enforcing",
+ "LOG_ONLY": "Log only",
+ "REACH": "Your reach",
+ "REACH_CLUSTER": "Cluster: every organisation",
+ "REACH_PARTNER": "Partner: every organisation under {{ name }}",
+ "REACH_ORGANISATION": "Organisation: {{ name }}"
}
}
diff --git a/public/assets/locale/en-US.json b/public/assets/locale/en-US.json
index 74f37f815..a638dae3b 100644
--- a/public/assets/locale/en-US.json
+++ b/public/assets/locale/en-US.json
@@ -201,7 +201,11 @@
"DATE_FROM_IN_MONTHS": "In {{ months }} month(s)",
"DATE_FROM_MONTHS_AGO": "{{ months }} month(s) ago",
"DATE_FROM_IN_YEAS": "In {{ years }} year(s)",
- "DATE_FROM_YEARS_AGO": "{{ years }} year(s) ago"
+ "DATE_FROM_YEARS_AGO": "{{ years }} year(s) ago",
+ "ORGANISATION": "Organisation",
+ "ORGANISATIONS": "Organisations",
+ "PARTNER": "Partner",
+ "PARTNERS": "Partners"
},
"SYSTEMS": {
"SINGULAR": "System",
@@ -976,7 +980,8 @@
"UPLOADS_LIB_REMOVE_LOADING": "Removing upload...",
"UPLOADS_LIB_FIELD_TYPE": "File Type",
"UPLOADS_LIB_FIELD_SIZE": "Size",
- "UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain"
+ "UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain",
+ "TAB_ORGANISATIONS": "Organisations"
},
"CASCADE": {
"RESOLVING": "Working out what would be removed...",
@@ -1027,5 +1032,63 @@
"ALL_GROUPS": "All groups",
"SEARCH_GROUPS": "Search groups",
"NO_MATCHING_GROUPS": "No matching groups"
+ },
+ "ORGANISATIONS": {
+ "SINGULAR": "Organisation",
+ "PLURAL": "Organisations",
+ "SWITCH": "Switch organisation",
+ "SEARCH": "Search organisations",
+ "ALL": "All organisations",
+ "NO_MATCHING": "No matching organisations",
+ "UNOWNED": "No organisation (cluster only)",
+ "HEADER": "Organisations",
+ "PARTNERS_HEADER": "Partners",
+ "ADD": "Add organisation",
+ "EDIT": "Edit organisation",
+ "NEW": "New organisation",
+ "REMOVE": "Remove organisation",
+ "REMOVE_MSG": "Remove the organisation {{ name }}? This fails while any domain or estate still belongs to it.",
+ "SAVING": "Saving organisation...",
+ "SAVE_SUCCESS": "Organisation saved",
+ "SAVE_ERROR": "Failed to save organisation. Error: {{ error }}",
+ "REMOVE_SUCCESS": "Organisation removed",
+ "REMOVE_ERROR": "Failed to remove organisation. Error: {{ error }}",
+ "NAME_REQUIRED": "Organisation name is required",
+ "FIELD_PARTNER": "Partner",
+ "SELF_MANAGED": "Self managed (no partner)",
+ "FIELD_PAYER": "Invoiced to",
+ "PAYER_PARTNER": "Partner",
+ "PAYER_ORGANISATION": "Organisation",
+ "FIELD_PARTNER_STAFF": "Partner staff organisation",
+ "PARTNER_STAFF_HINT": "Admins of this organisation reach every organisation under the partner",
+ "FIELD_DOMAINS": "Domains",
+ "CLAIM": "Claim unowned zones",
+ "CLAIM_TITLE": "Claim unowned zones for {{ name }}",
+ "CLAIM_DESC": "Root zones that no organisation owns. The zones chosen, their children, and the systems and modules inside them become this organisation's.",
+ "CLAIM_EMPTY": "Every root zone already has an owner",
+ "CLAIM_SUCCESS": "Claimed {{ zones }} zones, {{ systems }} systems and {{ modules }} modules",
+ "CLAIM_ERROR": "Failed to claim zones. Error: {{ error }}",
+ "CLAIM_ACTION": "Claim",
+ "PARTNER_ADD": "Add partner",
+ "PARTNER_EDIT": "Edit partner",
+ "PARTNER_NEW": "New partner",
+ "PARTNER_REMOVE": "Remove partner",
+ "PARTNER_REMOVE_MSG": "Remove the partner {{ name }}? This fails while any organisation still belongs to it.",
+ "PARTNER_SAVING": "Saving partner...",
+ "PARTNER_SAVE_SUCCESS": "Partner saved",
+ "PARTNER_SAVE_ERROR": "Failed to save partner. Error: {{ error }}",
+ "PARTNER_REMOVE_SUCCESS": "Partner removed",
+ "PARTNER_REMOVE_ERROR": "Failed to remove partner. Error: {{ error }}",
+ "PARTNER_NAME_REQUIRED": "Partner name is required",
+ "MANAGEMENT": "Management partner",
+ "FIELD_MANAGEMENT": "Management",
+ "EMPTY": "No organisations in reach",
+ "PARTNERS_EMPTY": "No partners in reach",
+ "ENFORCING": "Enforcing",
+ "LOG_ONLY": "Log only",
+ "REACH": "Your reach",
+ "REACH_CLUSTER": "Cluster: every organisation",
+ "REACH_PARTNER": "Partner: every organisation under {{ name }}",
+ "REACH_ORGANISATION": "Organisation: {{ name }}"
}
}
diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts
index 5dd076d86..1b99595bd 100644
--- a/src/app/admin/admin.component.ts
+++ b/src/app/admin/admin.component.ts
@@ -7,6 +7,11 @@ import { AsyncHandler } from '../common/async-handler.class';
import { PlaceDebugService } from '../common/debug.service';
import { ActiveItemService } from '../common/item.service';
import { i18n } from '../common/locale.service';
+import {
+ canAccessClusterSection,
+ isClusterReach,
+ isPartnerReach,
+} from '../common/organisation-access';
import { SettingsService } from '../common/settings.service';
import { DebugOutputComponent } from '../ui/debug-output.component';
import { IconComponent } from '../ui/icon.component';
@@ -104,6 +109,11 @@ export class PlaceComponent extends AsyncHandler implements OnInit {
public updateTabList() {
this.tab_list.set(
[
+ {
+ id: 'organisations',
+ name: i18n('ADMIN.TAB_ORGANISATIONS'),
+ icon: { value: 'corporate_fare' },
+ },
{
id: 'about',
name: i18n('ADMIN.TAB_ABOUT'),
@@ -184,7 +194,15 @@ export class PlaceComponent extends AsyncHandler implements OnInit {
name: i18n('ADMIN.TAB_BUILD_JOBS'),
icon: { value: 'laps' },
},
- ].concat(this.extensions),
+ ]
+ .filter(
+ (tab) =>
+ canAccessClusterSection(tab.id) &&
+ (tab.id !== 'organisations' ||
+ isClusterReach() ||
+ isPartnerReach()),
+ )
+ .concat(this.extensions),
);
}
diff --git a/src/app/admin/admin.routes.ts b/src/app/admin/admin.routes.ts
index a551839da..f0ba15fd0 100644
--- a/src/app/admin/admin.routes.ts
+++ b/src/app/admin/admin.routes.ts
@@ -16,6 +16,13 @@ export const ROUTES: Routes = [
(m) => m.PlaceDetailsComponent,
),
},
+ {
+ path: 'organisations',
+ loadComponent: () =>
+ import('./organisations.component').then(
+ (m) => m.AdminOrganisationsComponent,
+ ),
+ },
{
path: 'database',
loadComponent: () =>
diff --git a/src/app/admin/organisation-claim-modal.component.ts b/src/app/admin/organisation-claim-modal.component.ts
new file mode 100644
index 000000000..4248843c9
--- /dev/null
+++ b/src/app/admin/organisation-claim-modal.component.ts
@@ -0,0 +1,132 @@
+import { Component, inject, OnInit, signal } from '@angular/core';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatRippleModule } from '@angular/material/core';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogModule,
+ MatDialogRef,
+} from '@angular/material/dialog';
+import { MatProgressBarModule } from '@angular/material/progress-bar';
+import {
+ claimZonesForOrganisation,
+ PlaceOrganisation,
+ PlaceZone,
+ queryZones,
+} from '@placeos/ts-client';
+import { readError } from '../common/errors';
+import { i18n } from '../common/locale.service';
+import { notifyError, notifySuccess } from '../common/notifications';
+import { collectPages } from '../common/support-access';
+import { TranslatePipe } from '../ui/translate.pipe';
+
+@Component({
+ selector: 'organisation-claim-modal',
+ imports: [
+ MatCheckboxModule,
+ MatDialogModule,
+ MatProgressBarModule,
+ MatRippleModule,
+ TranslatePipe,
+ ],
+ template: `
+
+ {{
+ 'ORGANISATIONS.CLAIM_TITLE'
+ | translate: { name: organisation.name }
+ }}
+
+
+
+ {{ 'ORGANISATIONS.CLAIM_DESC' | translate }}
+
+
+
+ @for (zone of zones(); track zone.id) {
+
+ {{ zone.name }}
+ @if (zone.tags?.length) {
+
+ ({{ zone.tags.join(', ') }})
+
+ }
+
+ } @empty {
+ @if (!loading()) {
+
+ {{ 'ORGANISATIONS.CLAIM_EMPTY' | translate }}
+
+ }
+ }
+
+
+
+
+
+
+ `,
+})
+export class OrganisationClaimModalComponent implements OnInit {
+ private _dialog_ref =
+ inject>(MatDialogRef);
+ private _data = inject<{ organisation: PlaceOrganisation }>(
+ MAT_DIALOG_DATA,
+ );
+
+ public readonly organisation = this._data.organisation;
+ public readonly loading = signal(false);
+ public readonly zones = signal([]);
+ public readonly selected = signal(new Set());
+
+ public async ngOnInit() {
+ this.loading.set(true);
+ const roots = await collectPages(
+ queryZones({ parent_id: 'root', limit: 500 }),
+ ).catch(() => [] as PlaceZone[]);
+ this.zones.set(roots.filter((zone) => !zone.organisation_id));
+ this.loading.set(false);
+ }
+
+ public toggle(id: string) {
+ this.selected.update((set) => {
+ const next = new Set(set);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
+ public async claim() {
+ this.loading.set(true);
+ try {
+ const result = await claimZonesForOrganisation(
+ this.organisation.id,
+ [...this.selected()],
+ );
+ notifySuccess(i18n('ORGANISATIONS.CLAIM_SUCCESS', { ...result }));
+ this._dialog_ref.close(result);
+ } catch (err) {
+ notifyError(
+ i18n('ORGANISATIONS.CLAIM_ERROR', {
+ error: await readError(err),
+ }),
+ );
+ this.loading.set(false);
+ }
+ }
+}
diff --git a/src/app/admin/organisation-form.component.ts b/src/app/admin/organisation-form.component.ts
new file mode 100644
index 000000000..58184292c
--- /dev/null
+++ b/src/app/admin/organisation-form.component.ts
@@ -0,0 +1,225 @@
+import {
+ Component,
+ computed,
+ EventEmitter,
+ inject,
+ OnInit,
+ Output,
+ signal,
+} from '@angular/core';
+import { form, FormField, submit } from '@angular/forms/signals';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
+import {
+ addOrganisation,
+ PlaceOrganisation,
+ PlacePartner,
+ updateOrganisation,
+} from '@placeos/ts-client';
+import { AsyncHandler } from '../common/async-handler.class';
+import { readError } from '../common/errors';
+import { HotkeysService } from '../common/hotkeys.service';
+import { i18n } from '../common/locale.service';
+import { notifyError, notifySuccess } from '../common/notifications';
+import { DialogEvent } from '../common/types';
+import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
+import { SettingsToggleComponent } from '../ui/settings-toggle.component';
+import { TranslatePipe } from '../ui/translate.pipe';
+import {
+ applyOrganisationFormSchema,
+ generateOrganisationFormModel,
+} from './organisations.utilities';
+
+@Component({
+ selector: 'organisation-form',
+ template: `
+
+ @if (form) {
+
+ }
+
+ `,
+ imports: [
+ FormField,
+ FullscreenModalShellComponent,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSelectModule,
+ SettingsToggleComponent,
+ TranslatePipe,
+ ],
+})
+export class OrganisationFormComponent extends AsyncHandler implements OnInit {
+ private _dialog_ref =
+ inject>(MatDialogRef);
+ private _data = inject<{
+ item: PlaceOrganisation;
+ partners: PlacePartner[];
+ }>(MAT_DIALOG_DATA);
+ private _hotkey = inject(HotkeysService);
+
+ @Output() public event = new EventEmitter();
+
+ public readonly formModel = signal(
+ generateOrganisationFormModel(this._data.item),
+ );
+ public readonly form = form(this.formModel, applyOrganisationFormSchema);
+ public readonly loading = signal(null);
+ public readonly partners = signal(this._data.partners || []);
+ public readonly has_partner = computed(() => !!this.formModel().partner_id);
+ public heading = i18n(
+ `ORGANISATIONS.${this._data.item.id ? 'EDIT' : 'NEW'}`,
+ );
+
+ public ngOnInit(): void {
+ this.subscription(
+ 'save_item_key',
+ this._hotkey.listen(['KeyS'], () => this.submit()),
+ );
+ }
+
+ public async submit(): Promise {
+ await submit(this.form, async () => {
+ const item = this._data.item;
+ const model = this.formModel();
+ this.loading.set(i18n('ORGANISATIONS.SAVING'));
+ this._dialog_ref.disableClose = true;
+ const body = {
+ name: model.name,
+ description: model.description,
+ partner_id: model.partner_id || undefined,
+ } as Partial;
+ const options = model.partner_id
+ ? { payer: model.payer, partner_staff: model.partner_staff }
+ : { payer: 'organisation' as const, partner_staff: false };
+ const result = await (
+ item.id
+ ? updateOrganisation(item.id, body, options)
+ : addOrganisation(body, options)
+ ).catch(async (err) => {
+ this.loading.set(null);
+ this._dialog_ref.disableClose = false;
+ notifyError(
+ i18n('ORGANISATIONS.SAVE_ERROR', {
+ error: await readError(err),
+ }),
+ );
+ return null;
+ });
+ if (!result) return;
+ this._dialog_ref.disableClose = false;
+ this.event.emit({ reason: 'done', metadata: { item: result } });
+ notifySuccess(i18n('ORGANISATIONS.SAVE_SUCCESS'));
+ this._dialog_ref.close();
+ });
+ }
+}
diff --git a/src/app/admin/organisations.component.ts b/src/app/admin/organisations.component.ts
new file mode 100644
index 000000000..067b2a9b2
--- /dev/null
+++ b/src/app/admin/organisations.component.ts
@@ -0,0 +1,418 @@
+import { Component, computed, inject, OnInit, signal } from '@angular/core';
+import { MatRippleModule } from '@angular/material/core';
+import { MatDialog } from '@angular/material/dialog';
+import { MatProgressBarModule } from '@angular/material/progress-bar';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import {
+ PlaceOrganisation,
+ PlacePartner,
+ queryOrganisations,
+ queryPartners,
+ removeOrganisation,
+ removePartner,
+} from '@placeos/ts-client';
+
+import { AsyncHandler } from '../common/async-handler.class';
+import { describeError } from '../common/errors';
+import { i18n } from '../common/locale.service';
+import { notifyError, notifySuccess } from '../common/notifications';
+import {
+ current_organisation,
+ current_partner,
+ isClusterReach,
+ reach,
+ tenancyEnforced,
+} from '../common/organisation-access';
+import { collectPages } from '../common/support-access';
+import { FormModalComponent } from '../common/types';
+import { openConfirmModal } from '../overlays/confirm-modal.component';
+import { IconComponent } from '../ui/icon.component';
+import { SimpleTableComponent } from '../ui/simple-table.component';
+import { TranslatePipe } from '../ui/translate.pipe';
+import { OrganisationClaimModalComponent } from './organisation-claim-modal.component';
+import { OrganisationFormComponent } from './organisation-form.component';
+import { PartnerFormComponent } from './partner-form.component';
+
+@Component({
+ selector: 'app-organisations',
+ template: `
+
+
+ corporate_fare
+ {{ 'ORGANISATIONS.REACH' | translate }}:
+ {{ reach_label() }}
+
+ {{
+ (enforcing()
+ ? 'ORGANISATIONS.ENFORCING'
+ : 'ORGANISATIONS.LOG_ONLY'
+ ) | translate
+ }}
+
+
+
+
+ {{ 'ORGANISATIONS.HEADER' | translate }}
+
+ @if (can_manage()) {
+
+ }
+
+
+
+
+
+
+
+ {{ 'ORGANISATIONS.PARTNERS_HEADER' | translate }}
+
+ @if (can_manage()) {
+
+ }
+
+
+
+
+
+
+
+ {{
+ partnerName(data) ||
+ ('ORGANISATIONS.SELF_MANAGED' | translate)
+ }}
+
+
+
+
+ @if (data) {
+ check
+ }
+
+
+
+
+ @if (data) {
+ verified
+ }
+
+
+
+ @if (can_manage()) {
+
+
+
+
+
+ }
+
+
+ @if (can_manage()) {
+
+
+
+
+ }
+
+ `,
+ imports: [
+ IconComponent,
+ MatRippleModule,
+ MatTooltipModule,
+ TranslatePipe,
+ SimpleTableComponent,
+ MatProgressBarModule,
+ ],
+})
+export class AdminOrganisationsComponent
+ extends AsyncHandler
+ implements OnInit
+{
+ private _dialog = inject(MatDialog);
+
+ public readonly loading = signal(false);
+ public readonly organisations = signal([]);
+ public readonly partners = signal([]);
+ public readonly can_manage = isClusterReach;
+ public readonly enforcing = tenancyEnforced;
+ public readonly reach_label = computed(() => {
+ const level = reach()?.reach;
+ if (!level || level === 'cluster') {
+ return i18n('ORGANISATIONS.REACH_CLUSTER');
+ }
+ if (level === 'partner') {
+ return i18n('ORGANISATIONS.REACH_PARTNER', {
+ name: current_partner()?.name || '',
+ });
+ }
+ return i18n('ORGANISATIONS.REACH_ORGANISATION', {
+ name: current_organisation()?.name || '',
+ });
+ });
+
+ public ngOnInit() {
+ this.load();
+ }
+
+ public partnerName(id: string) {
+ return this.partners().find((partner) => partner.id === id)?.name || '';
+ }
+
+ public async load() {
+ this.loading.set(true);
+ const [organisations, partners] = await Promise.all([
+ collectPages(queryOrganisations({ limit: 200 })).catch(
+ () => [] as PlaceOrganisation[],
+ ),
+ collectPages(queryPartners({ limit: 200 })).catch(
+ () => [] as PlacePartner[],
+ ),
+ ]);
+ this.organisations.set(
+ organisations.sort((a, b) => a.name.localeCompare(b.name)),
+ );
+ this.partners.set(
+ partners.sort((a, b) => a.name.localeCompare(b.name)),
+ );
+ this.loading.set(false);
+ }
+
+ public newOrganisation(): void {
+ this.openOrganisationForm(new PlaceOrganisation());
+ }
+
+ public editOrganisation(item: PlaceOrganisation): void {
+ this.openOrganisationForm(item);
+ }
+
+ private openOrganisationForm(item: PlaceOrganisation) {
+ const ref = this._dialog.open(OrganisationFormComponent, {
+ data: { item, partners: this.partners() },
+ });
+ this.subscription(
+ 'modal_events',
+ (
+ ref.componentInstance as unknown as FormModalComponent
+ ).event.subscribe((event) => {
+ if (event.reason !== 'done') return;
+ this.load();
+ }),
+ );
+ }
+
+ public async deleteOrganisation(item: PlaceOrganisation) {
+ const details = await openConfirmModal(
+ {
+ title: i18n('ORGANISATIONS.REMOVE'),
+ content: i18n('ORGANISATIONS.REMOVE_MSG', { name: item.name }),
+ icon: { type: 'icon', content: 'delete' },
+ },
+ this._dialog,
+ );
+ if (details.reason !== 'done') return;
+ details.loading(i18n('COMMON.LOADING'));
+ try {
+ await removeOrganisation(item.id);
+ notifySuccess(i18n('ORGANISATIONS.REMOVE_SUCCESS'));
+ this.load();
+ } catch (err) {
+ notifyError(
+ i18n('ORGANISATIONS.REMOVE_ERROR', {
+ error: await describeError(err),
+ }),
+ );
+ }
+ details.close();
+ }
+
+ public claimZones(item: PlaceOrganisation) {
+ this._dialog
+ .open(OrganisationClaimModalComponent, {
+ data: { organisation: item },
+ })
+ .afterClosed()
+ .subscribe((result) => {
+ if (result) this.load();
+ });
+ }
+
+ public newPartner(): void {
+ this.openPartnerForm(new PlacePartner());
+ }
+
+ public editPartner(item: PlacePartner): void {
+ this.openPartnerForm(item);
+ }
+
+ private openPartnerForm(item: PlacePartner) {
+ const ref = this._dialog.open(PartnerFormComponent, { data: { item } });
+ this.subscription(
+ 'modal_events',
+ (
+ ref.componentInstance as unknown as FormModalComponent
+ ).event.subscribe((event) => {
+ if (event.reason !== 'done') return;
+ this.load();
+ }),
+ );
+ }
+
+ public async deletePartner(item: PlacePartner) {
+ const details = await openConfirmModal(
+ {
+ title: i18n('ORGANISATIONS.PARTNER_REMOVE'),
+ content: i18n('ORGANISATIONS.PARTNER_REMOVE_MSG', {
+ name: item.name,
+ }),
+ icon: { type: 'icon', content: 'delete' },
+ },
+ this._dialog,
+ );
+ if (details.reason !== 'done') return;
+ details.loading(i18n('COMMON.LOADING'));
+ try {
+ await removePartner(item.id);
+ notifySuccess(i18n('ORGANISATIONS.PARTNER_REMOVE_SUCCESS'));
+ this.load();
+ } catch (err) {
+ notifyError(
+ i18n('ORGANISATIONS.PARTNER_REMOVE_ERROR', {
+ error: await describeError(err),
+ }),
+ );
+ }
+ details.close();
+ }
+}
diff --git a/src/app/admin/organisations.utilities.ts b/src/app/admin/organisations.utilities.ts
new file mode 100644
index 000000000..74569c6cd
--- /dev/null
+++ b/src/app/admin/organisations.utilities.ts
@@ -0,0 +1,46 @@
+import { required, SchemaFn } from '@angular/forms/signals';
+import { PlaceOrganisation, PlacePartner } from '@placeos/ts-client';
+
+export interface OrganisationFormModel {
+ name: string;
+ description: string;
+ partner_id: string;
+ payer: 'partner' | 'organisation';
+ partner_staff: boolean;
+}
+
+export function generateOrganisationFormModel(
+ organisation: Partial = {},
+): OrganisationFormModel {
+ return {
+ name: organisation.name || '',
+ description: organisation.description || '',
+ partner_id: organisation.partner_id || '',
+ payer: organisation.payer || 'organisation',
+ partner_staff: !!organisation.partner_staff,
+ };
+}
+
+export const applyOrganisationFormSchema: SchemaFn = (
+ path,
+) => {
+ required(path.name);
+};
+
+export interface PartnerFormModel {
+ name: string;
+ description: string;
+}
+
+export function generatePartnerFormModel(
+ partner: Partial = {},
+): PartnerFormModel {
+ return {
+ name: partner.name || '',
+ description: partner.description || '',
+ };
+}
+
+export const applyPartnerFormSchema: SchemaFn = (path) => {
+ required(path.name);
+};
diff --git a/src/app/admin/partner-form.component.ts b/src/app/admin/partner-form.component.ts
new file mode 100644
index 000000000..fbef8e73d
--- /dev/null
+++ b/src/app/admin/partner-form.component.ts
@@ -0,0 +1,136 @@
+import {
+ Component,
+ EventEmitter,
+ inject,
+ OnInit,
+ Output,
+ signal,
+} from '@angular/core';
+import { form, FormField, submit } from '@angular/forms/signals';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { addPartner, PlacePartner, updatePartner } from '@placeos/ts-client';
+import { AsyncHandler } from '../common/async-handler.class';
+import { readError } from '../common/errors';
+import { HotkeysService } from '../common/hotkeys.service';
+import { i18n } from '../common/locale.service';
+import { notifyError, notifySuccess } from '../common/notifications';
+import { DialogEvent } from '../common/types';
+import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
+import { TranslatePipe } from '../ui/translate.pipe';
+import {
+ applyPartnerFormSchema,
+ generatePartnerFormModel,
+} from './organisations.utilities';
+
+@Component({
+ selector: 'partner-form',
+ template: `
+
+ @if (form) {
+
+ }
+
+ `,
+ imports: [
+ FormField,
+ FullscreenModalShellComponent,
+ MatFormFieldModule,
+ MatInputModule,
+ TranslatePipe,
+ ],
+})
+export class PartnerFormComponent extends AsyncHandler implements OnInit {
+ private _dialog_ref =
+ inject>(MatDialogRef);
+ private _data = inject<{ item: PlacePartner }>(MAT_DIALOG_DATA);
+ private _hotkey = inject(HotkeysService);
+
+ @Output() public event = new EventEmitter();
+
+ public readonly formModel = signal(
+ generatePartnerFormModel(this._data.item),
+ );
+ public readonly form = form(this.formModel, applyPartnerFormSchema);
+ public readonly loading = signal(null);
+ public heading = i18n(
+ `ORGANISATIONS.PARTNER_${this._data.item.id ? 'EDIT' : 'NEW'}`,
+ );
+
+ public ngOnInit(): void {
+ this.subscription(
+ 'save_item_key',
+ this._hotkey.listen(['KeyS'], () => this.submit()),
+ );
+ }
+
+ public async submit(): Promise {
+ await submit(this.form, async () => {
+ const item = this._data.item;
+ this.loading.set(i18n('ORGANISATIONS.PARTNER_SAVING'));
+ this._dialog_ref.disableClose = true;
+ const result = await (
+ item.id
+ ? updatePartner(item.id, this.formModel())
+ : addPartner(this.formModel())
+ ).catch(async (err) => {
+ this.loading.set(null);
+ this._dialog_ref.disableClose = false;
+ notifyError(
+ i18n('ORGANISATIONS.PARTNER_SAVE_ERROR', {
+ error: await readError(err),
+ }),
+ );
+ return null;
+ });
+ if (!result) return;
+ this._dialog_ref.disableClose = false;
+ this.event.emit({ reason: 'done', metadata: { item: result } });
+ notifySuccess(i18n('ORGANISATIONS.PARTNER_SAVE_SUCCESS'));
+ this._dialog_ref.close();
+ });
+ }
+}
diff --git a/src/app/common/actions.ts b/src/app/common/actions.ts
index 5c833f3bb..23fb3cd53 100644
--- a/src/app/common/actions.ts
+++ b/src/app/common/actions.ts
@@ -62,6 +62,7 @@ import {
planDomainCascade,
planZoneCascade,
} from './cascade-delete';
+import { organisationQueryParams } from './organisation-access';
import {
querySupportModules,
querySupportSystems as querySystems,
@@ -103,6 +104,7 @@ export interface ItemActions {
const domains: ItemActions = {
query: (_) =>
queryDomains({
+ ...organisationQueryParams(),
q: _,
fields: ['id', 'name', 'domain'].join(','),
}),
@@ -216,6 +218,7 @@ const repositories: ItemActions = {
const systems: ItemActions = {
query: (_) =>
querySystems({
+ ...organisationQueryParams(),
q: _,
fields: ['id', 'name', 'display_name'].join(','),
}),
@@ -316,6 +319,7 @@ function processURL(system: PlaceSystem, url: string) {
const triggers: ItemActions = {
query: (_) =>
queryTriggers({
+ ...organisationQueryParams(),
q: _,
fields: ['id', 'name', 'display_name'].join(','),
}),
@@ -334,6 +338,7 @@ const triggers: ItemActions = {
const users: ItemActions = {
query: (_, options) =>
queryUsers({
+ ...organisationQueryParams(),
...options,
q: _,
fields: [
@@ -358,6 +363,7 @@ const users: ItemActions = {
const zones: ItemActions = {
query: (_) =>
queryZones({
+ ...organisationQueryParams(),
q: _,
fields: ['id', 'name', 'display_name', 'tags'].join(','),
}),
diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts
index 497073cf8..9f87baea6 100644
--- a/src/app/common/item.service.ts
+++ b/src/app/common/item.service.ts
@@ -37,6 +37,10 @@ import { describeError } from './errors';
import { escapeHtml, log } from './general';
import { i18n } from './locale.service';
import { notifyError, notifySuccess } from './notifications';
+import {
+ selectOrganisation,
+ selected_organisation_id,
+} from './organisation-access';
import { waitForEvent, waitForSignalValue } from './signals';
import {
canAccessSection,
@@ -148,6 +152,21 @@ export class ActiveItemService extends AsyncHandler {
);
}
+ /** Narrow every list to one organisation, or `''` for all in reach */
+ public switchOrganisation(id: string) {
+ if (id === selected_organisation_id() || !selectOrganisation(id))
+ return;
+ this._scope_version++;
+ this._active_item.set(null);
+ this._next_query.set(null);
+ this._list.set([]);
+ this._count.set(0);
+ this._search.set('');
+ this._loading.set(false);
+ this._loading_list.set(false);
+ this._router.navigate(['/', this.type || 'systems', '-']);
+ }
+
public switchGroup(id: string) {
if (id === selected_support_group_id() || !selectSupportGroup(id))
return;
diff --git a/src/app/common/organisation-access.ts b/src/app/common/organisation-access.ts
new file mode 100644
index 000000000..05bd61820
--- /dev/null
+++ b/src/app/common/organisation-access.ts
@@ -0,0 +1,112 @@
+import { computed, signal } from '@angular/core';
+import {
+ currentReach,
+ PlaceOrganisation,
+ PlaceReach,
+ PlaceUser,
+ queryOrganisations,
+ QueryResponse,
+} from '@placeos/ts-client';
+
+/** Load every page of a query. Throws after 1000 extra pages. */
+async function collectPages(response: QueryResponse) {
+ let page = await response;
+ const data = [...page.data];
+ for (let i = 0; page.next && i < 1000; i++) {
+ page = await page.next();
+ data.push(...page.data);
+ }
+ if (page.next) throw new Error('Too many resource pages');
+ return data;
+}
+
+let storage_key = '';
+const selected_id = signal('');
+const _reach = signal(null);
+const _organisations = signal([]);
+
+/** The caller's reach as the API resolved it; null when the API has no organisations yet */
+export const reach = _reach.asReadonly();
+/** Organisations the caller may pick between */
+export const organisations = _organisations.asReadonly();
+export const selected_organisation_id = selected_id.asReadonly();
+
+/** Cluster staff see every organisation. An API without organisations behaves the same way. */
+export const isClusterReach = computed(
+ () => !_reach() || _reach().reach === 'cluster',
+);
+export const isPartnerReach = computed(() => _reach()?.reach === 'partner');
+export const tenancyEnforced = computed(() => !!_reach()?.enforcing);
+export const current_organisation = computed(
+ () => _reach()?.organisation || null,
+);
+export const current_partner = computed(() => _reach()?.partner || null);
+export const selected_organisation = computed(
+ () => _organisations().find(({ id }) => id === selected_id()) || null,
+);
+/** A picker is useful once there is more than one organisation to choose from */
+export const hasOrganisationPicker = computed(
+ () => !!_reach() && _organisations().length > 1,
+);
+
+/** Cluster-only screens. Everything else is scoped by the API. */
+const CLUSTER_SECTIONS = [
+ 'repositories',
+ 'database',
+ 'clusters',
+ 'interfaces',
+ 'brokers',
+ 'extensions',
+ 'schemas',
+ 'build-jobs',
+];
+
+export function canAccessClusterSection(section: string) {
+ return !CLUSTER_SECTIONS.includes(section) || isClusterReach();
+}
+
+/** Select an organisation for every list, or `''` for all organisations in reach */
+export function selectOrganisation(id: string) {
+ if (id && !_organisations().some((item) => item.id === id)) return false;
+ selected_id.set(id);
+ try {
+ localStorage.setItem(storage_key, id);
+ } catch {
+ /* Storage is optional. */
+ }
+ return true;
+}
+
+/** Query parameters that narrow a list to the selected organisation */
+export function organisationQueryParams(): { organisation_id?: string } {
+ return selected_id() ? { organisation_id: selected_id() } : {};
+}
+
+export function organisationName(id: string) {
+ return _organisations().find((item) => item.id === id)?.name || '';
+}
+
+/** Load the caller's reach and the organisations they may pick between */
+export async function loadOrganisationAccess(user: PlaceUser) {
+ _reach.set(null);
+ _organisations.set([]);
+ selected_id.set('');
+ storage_key = `BACKOFFICE.organisation.${user.authority_id}.${user.id}`;
+ const reach = await currentReach().catch(() => null);
+ if (!reach) return;
+ const list = reach.organisations
+ ? reach.organisations
+ : await collectPages(queryOrganisations({ limit: 200 })).catch(
+ () => [] as PlaceOrganisation[],
+ );
+ list.sort((a, b) => a.name.localeCompare(b.name));
+ _reach.set(reach);
+ _organisations.set(list);
+ let saved = '';
+ try {
+ saved = localStorage.getItem(storage_key) || '';
+ } catch {
+ /* Storage is optional. */
+ }
+ selected_id.set(list.some(({ id }) => id === saved) ? saved : '');
+}
diff --git a/src/app/common/support-access.ts b/src/app/common/support-access.ts
index 4b9d08782..a601a1650 100644
--- a/src/app/common/support-access.ts
+++ b/src/app/common/support-access.ts
@@ -12,6 +12,7 @@ import {
querySystems,
queryZones,
} from '@placeos/ts-client';
+import { organisationQueryParams } from './organisation-access';
let storage_key = '';
const selected_id = signal('');
@@ -141,6 +142,7 @@ export function querySupportSystems(
return Promise.resolve({ data: [], total: 0, next: null });
}
return querySystems({
+ ...organisationQueryParams(),
...options,
...(isSubsystemUser() ? { subsystem: 'support' } : {}),
...(selected_id() ? { group_id: selected_id() } : {}),
@@ -152,7 +154,7 @@ export function querySupportZones(
options: PlaceZoneQueryOptions = {},
): QueryResponse {
if ((!isSubsystemUser() && !selected_id()) || options.parent_id) {
- return queryZones(options);
+ return queryZones({ ...organisationQueryParams(), ...options });
}
const groups = active_groups().filter(
({ permissions }) => hasSupportRole() || !!(permissions & 1),
@@ -176,7 +178,7 @@ export async function querySupportModules(
options: Parameters[0] = {},
): QueryResponse {
if (!selected_id() || options.control_system_id)
- return queryModules(options);
+ return queryModules({ ...organisationQueryParams(), ...options });
const requested_group = selected_id();
const systems = await collectPages(querySupportSystems({ limit: 200 }));
const modules: PlaceModule[] = [];
diff --git a/src/app/domains/domain-about.component.ts b/src/app/domains/domain-about.component.ts
index e790e6001..d271ddaa7 100644
--- a/src/app/domains/domain-about.component.ts
+++ b/src/app/domains/domain-about.component.ts
@@ -12,6 +12,7 @@ import { AsyncHandler } from '../common/async-handler.class';
import { readError } from '../common/errors';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
+import { organisationName } from '../common/organisation-access';
import { validateJSONString } from '../common/validation';
import { SettingsFieldComponent } from '../ui/custom-fields/settings-field.component';
import { IconComponent } from '../ui/icon.component';
@@ -22,6 +23,18 @@ import { DomainStateService } from './domain-state.service';
@Component({
selector: 'app-domain-about',
template: `
+ @if (item?.organisation_id) {
+
+ corporate_fare
+ {{ 'ORGANISATIONS.SINGULAR' | translate }}:
+ {{
+ organisationName(item.organisation_id) ||
+ item.organisation_id
+ }}
+
+ }
@if (item?.description) {
}
+ @if (form.organisation_id && show_organisation()) {
+
+
+
+
+
+ {{
+ 'ORGANISATIONS.UNOWNED' | translate
+ }}
+
+ @for (
+ item of organisation_list();
+ track item.id
+ ) {
+
+ {{ item.name }}
+
+ }
+
+
+
+ }
@if (form.description) {
}
+ @if (has_organisation_picker()) {
+
+ }
@if (support_groups().length) {