From 6c851365ac8707a57f80fada9ed381639ae4b923 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 7 Oct 2026 15:27:10 +1100 Subject: [PATCH] feat(organisations): organisation reach, picker and admin screen (PPT-2702) Reads the caller's reach after login, adds an organisation picker that narrows every list, an Admin > Organisations page for partners, organisations and claiming unowned zones, hides cluster-only screens from partner and organisation admins, and lets cluster staff set a domain's owner. ts-client comes from the built PPT-526-tenancy-dist branch until ts-client#31 publishes. --- bun.lock | 5 +- package.json | 5 +- public/assets/locale/en-AU.json | 71 ++- public/assets/locale/en-GB.json | 67 ++- public/assets/locale/en-US.json | 67 ++- src/app/admin/admin.component.ts | 20 +- src/app/admin/admin.routes.ts | 7 + .../organisation-claim-modal.component.ts | 132 ++++++ src/app/admin/organisation-form.component.ts | 225 ++++++++++ src/app/admin/organisations.component.ts | 418 ++++++++++++++++++ src/app/admin/organisations.utilities.ts | 46 ++ src/app/admin/partner-form.component.ts | 136 ++++++ src/app/common/actions.ts | 6 + src/app/common/item.service.ts | 19 + src/app/common/organisation-access.ts | 112 +++++ src/app/common/support-access.ts | 6 +- src/app/domains/domain-about.component.ts | 14 + src/app/domains/domain-form.component.ts | 45 +- src/app/domains/domains.utilities.ts | 4 +- src/app/ui/organisation-picker.component.ts | 89 ++++ src/app/ui/sidebar-menu.component.ts | 41 +- src/app/users/users.service.ts | 2 + src/tests/common/organisation-access.spec.ts | 113 +++++ src/tests/domains/domains.utilities.spec.ts | 1 + 24 files changed, 1633 insertions(+), 18 deletions(-) create mode 100644 src/app/admin/organisation-claim-modal.component.ts create mode 100644 src/app/admin/organisation-form.component.ts create mode 100644 src/app/admin/organisations.component.ts create mode 100644 src/app/admin/organisations.utilities.ts create mode 100644 src/app/admin/partner-form.component.ts create mode 100644 src/app/common/organisation-access.ts create mode 100644 src/app/ui/organisation-picker.component.ts create mode 100644 src/tests/common/organisation-access.spec.ts 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) { +
+
+ + + + {{ + 'ORGANISATIONS.NAME_REQUIRED' | translate + }} + +
+
+ + + + +
+
+ + + + + {{ + 'ORGANISATIONS.SELF_MANAGED' | translate + }} + + @for (partner of partners(); track partner.id) { + + {{ partner.name }} + + } + + +
+ @if (has_partner()) { +
+ + + + + {{ + 'ORGANISATIONS.PAYER_PARTNER' + | translate + }} + + + {{ + 'ORGANISATIONS.PAYER_ORGANISATION' + | translate + }} + + + +
+
+ +

+ {{ + 'ORGANISATIONS.PARTNER_STAFF_HINT' + | translate + }} +

+
+ } +
+ } +
+ `, + 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) { +
+
+ + + + {{ + 'ORGANISATIONS.PARTNER_NAME_REQUIRED' + | translate + }} + +
+
+ + + + +
+
+ } +
+ `, + 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) {