@@ -524,7 +524,12 @@
NPA
-
+
Loan Origination
@@ -586,7 +591,7 @@
class="ni"
tabindex="0"
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
- *mifosxHasPermission="'MENU_RBI'"
+ *mifosxHasPermission="'MENU_INVESTMENT'"
>
Investment
diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts
index 9ce65daac..8460bcee5 100644
--- a/src/app/core/shell/shell.component.ts
+++ b/src/app/core/shell/shell.component.ts
@@ -13,6 +13,7 @@ import { MatDialog } from '@angular/material/dialog';
import { Router, NavigationEnd } from '@angular/router';
import { AuthenticationService } from '../authentication/authentication.service';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
+import { TenantMenuAccessService } from '../../directives/has-permission/tenant-menu-access.service';
/** rxjs Imports */
import { Observable, Subscription } from 'rxjs';
@@ -66,6 +67,7 @@ export class ShellComponent implements OnInit, OnDestroy {
private router = inject(Router);
private authService = inject(AuthenticationService);
private clerkUiStateService = inject(ClerkUiStateService);
+ private tenantMenuAccessService = inject(TenantMenuAccessService);
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable = this.breakpointObserver
@@ -79,6 +81,10 @@ export class ShellComponent implements OnInit, OnDestroy {
progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */
hideShellLayout = false;
+ /** True once this tenant's own menu-access ceiling has loaded — the nav waits on this so a menu
+ * the tenant has turned off is never shown for an instant before disappearing. See
+ * TenantMenuAccessService. */
+ menuAccessReady = false;
private routeSub: Subscription;
/** Account Facilities — populated from ClerkUiStateService when account loads */
@@ -143,6 +149,11 @@ export class ShellComponent implements OnInit, OnDestroy {
this.cdr.detectChanges();
});
+ this.tenantMenuAccessService.load().subscribe(() => {
+ this.menuAccessReady = true;
+ this.cdr.detectChanges();
+ });
+
// Cashier-only users: hide shell. Clerk/dashboard: also fullscreen by default.
const checkLayout = () => {
const url = this.router.url;
diff --git a/src/app/directives/has-permission/has-permission.directive.ts b/src/app/directives/has-permission/has-permission.directive.ts
index bc64bc295..7ecc02001 100644
--- a/src/app/directives/has-permission/has-permission.directive.ts
+++ b/src/app/directives/has-permission/has-permission.directive.ts
@@ -11,6 +11,7 @@ import { Directive, Input, TemplateRef, ViewContainerRef, inject } from '@angula
/** Custom Services */
import { AuthenticationService } from '../../core/authentication/authentication.service';
+import { TenantMenuAccessService } from './tenant-menu-access.service';
/** Environment */
import { environment } from '../../../environments/environment';
@@ -23,6 +24,7 @@ export class HasPermissionDirective {
private templateRef = inject>(TemplateRef);
private viewContainer = inject(ViewContainerRef);
private authenticationService = inject(AuthenticationService);
+ private tenantMenuAccessService = inject(TenantMenuAccessService);
/** User Permissions */
private userPermissions: any[];
@@ -75,6 +77,13 @@ export class HasPermissionDirective {
// Menu & Sidebar permissions are ALWAYS enforced (hide/show nav items based on role)
const isMenuPermission = permission.startsWith('MENU_') || permission.startsWith('SIDEBAR_');
+ // The tenant's own ceiling on which menu sections it may ever show — checked before anything
+ // else below, ALL_FUNCTIONS included, because ALL_FUNCTIONS bypassing this is exactly the gap
+ // this exists to close (see TenantMenuAccessApiResource). Non-menu permissions never touch this.
+ if (isMenuPermission && !this.tenantMenuAccessService.isMenuAllowed(permission)) {
+ return false;
+ }
+
// For non-menu permissions: if RBAC is disabled, show everything (backward compatibility)
if (!this.rbacEnabled && !isMenuPermission) {
return true;
diff --git a/src/app/directives/has-permission/tenant-menu-access.service.ts b/src/app/directives/has-permission/tenant-menu-access.service.ts
new file mode 100644
index 000000000..55b5e78c9
--- /dev/null
+++ b/src/app/directives/has-permission/tenant-menu-access.service.ts
@@ -0,0 +1,61 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { catchError, of, tap } from 'rxjs';
+
+/**
+ * The current tenant's own cap on which top-nav menu codes may ever show — see
+ * `TenantMenuAccessApiResource` on the backend for why this exists separately from a user's own
+ * permission list: a role carrying `ALL_FUNCTIONS` bypasses every ordinary permission check, so
+ * `HasPermissionDirective` needs a second, tenant-level list that check cannot see past.
+ *
+ * Loaded once per session, into a plain `Set` rather than an `Observable` the directive would have
+ * to subscribe to — `mifosxHasPermission`'s setter runs synchronously and once per binding, so
+ * `ShellComponent` loads this before the nav renders (see `ready`) and every directive instance
+ * afterwards just reads the already-populated set.
+ */
+@Injectable({ providedIn: 'root' })
+export class TenantMenuAccessService {
+ private http = inject(HttpClient);
+
+ /** Menu codes this tenant has explicitly turned off. Absence from this set means "shown" —
+ * a tenant that never configured anything (every tenant, until an operator narrows one) has
+ * nothing here, which is what keeps every existing deployment behaving exactly as it does today. */
+ private disabledCodes = new Set();
+
+ /** True once the initial load has settled, success or failure. `ShellComponent` waits on this
+ * before rendering the nav, so a menu is never shown-then-hidden a moment later. */
+ private loaded = false;
+
+ isReady(): boolean {
+ return this.loaded;
+ }
+
+ isMenuAllowed(menuCode: string): boolean {
+ return !this.disabledCodes.has(menuCode);
+ }
+
+ /** Fetches this tenant's list once. A failed request is treated as "nothing disabled" — the same
+ * as a tenant that never configured this at all — so a transient network hiccup on login never
+ * makes the whole nav disappear. */
+ load() {
+ return this.http.get<{ menuCode: string; enabled: boolean }[]>('/tenant-menu-access').pipe(
+ tap((rows) => {
+ this.disabledCodes = new Set((rows || []).filter((r) => r.enabled === false).map((r) => r.menuCode));
+ this.loaded = true;
+ }),
+ catchError(() => {
+ this.disabledCodes = new Set();
+ this.loaded = true;
+ return of([]);
+ })
+ );
+ }
+}
diff --git a/src/app/platform-admin/menu-access/menu-access.component.html b/src/app/platform-admin/menu-access/menu-access.component.html
new file mode 100644
index 000000000..9ff4d2959
--- /dev/null
+++ b/src/app/platform-admin/menu-access/menu-access.component.html
@@ -0,0 +1,135 @@
+
+
+ {
+ return {
+ 'X-Api-Key': this.apiKey,
+ // Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for
+ // read/write is this header's own value, so it is set to whichever tenant is actually in play.
+ 'Fineract-Platform-TenantId': tenantForFilter || this.selectedTenant || (this.tenants[0]?.identifier ?? 'default')
+ };
+ }
+
+ private apiUrl(path: string): string {
+ return `${environment.serverUrl}${path}`;
+ }
+
+ rememberKey(): void {
+ localStorage.setItem(PlatformMenuAccessComponent.KEY_STORAGE, this.apiKey);
+ }
+
+ async loadTenants(): Promise {
+ this.error = null;
+ this.savedMessage = null;
+ if (!this.apiKey.trim()) {
+ this.error = 'Enter the platform API key first.';
+ return;
+ }
+ this.rememberKey();
+ this.loadingTenants = true;
+ try {
+ const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') });
+ if (!res.ok) {
+ throw new Error(
+ res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).`
+ );
+ }
+ this.tenants = await res.json();
+ if (this.tenants.length > 0 && !this.selectedTenant) {
+ this.selectedTenant = this.tenants[0].identifier;
+ await this.loadMenusForSelectedTenant();
+ }
+ } catch (e: any) {
+ this.error = e?.message ?? 'Could not load the tenant list.';
+ this.tenants = [];
+ } finally {
+ this.loadingTenants = false;
+ }
+ }
+
+ async loadMenusForSelectedTenant(): Promise {
+ this.error = null;
+ this.savedMessage = null;
+ if (!this.selectedTenant) {
+ return;
+ }
+ this.loadingMenus = true;
+ try {
+ const res = await fetch(this.apiUrl('/tenant-menu-access'), { headers: this.headers() });
+ if (!res.ok) {
+ throw new Error(`Could not read this tenant's menu list (${res.status}).`);
+ }
+ const saved: { menuCode: string; enabled: boolean }[] = await res.json();
+ const enabledByCode = new Map(
+ saved.map((r) => [
+ r.menuCode,
+ r.enabled
+ ])
+ );
+ // A code the tenant has never had a row saved for yet reads as enabled — matches the backend's
+ // own default (0541 seeds every existing tenant with everything on), so a freshly onboarded
+ // bank that nobody has touched here shows its real, unrestricted state, not a false "all off".
+ this.rows = PlatformMenuAccessComponent.ALL_MENUS.map((m) => ({
+ code: m.code,
+ label: m.label,
+ icon: m.icon,
+ enabled: enabledByCode.has(m.code) ? !!enabledByCode.get(m.code) : true
+ }));
+ } catch (e: any) {
+ this.error = e?.message ?? "Could not load this tenant's menu list.";
+ this.rows = [];
+ } finally {
+ this.loadingMenus = false;
+ }
+ }
+
+ async save(): Promise {
+ this.error = null;
+ this.savedMessage = null;
+ this.saving = true;
+ try {
+ const body = this.rows.map((r) => ({ menuCode: r.code, enabled: r.enabled }));
+ const res = await fetch(this.apiUrl('/tenant-menu-access'), {
+ method: 'PUT',
+ headers: { ...this.headers(), 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ if (!res.ok) {
+ throw new Error(`Save failed (${res.status}).`);
+ }
+ this.savedMessage = `Saved for "${this.selectedTenant}".`;
+ } catch (e: any) {
+ this.error = e?.message ?? 'Could not save.';
+ } finally {
+ this.saving = false;
+ }
+ }
+
+ setAll(enabled: boolean): void {
+ this.rows = this.rows.map((r) => ({ ...r, enabled }));
+ }
+
+ get enabledCount(): number {
+ return this.rows.filter((r) => r.enabled).length;
+ }
+
+ get selectedTenantName(): string {
+ return this.tenants.find((t) => t.identifier === this.selectedTenant)?.name ?? this.selectedTenant;
+ }
+}
diff --git a/src/assets/env.js b/src/assets/env.js
index 3d4e606c0..3b7c7551c 100644
--- a/src/assets/env.js
+++ b/src/assets/env.js
@@ -19,9 +19,9 @@
// Tenants the login screen offers, and which one it starts on. Every identifier here has to exist
// in the tenants table of the tenant-store database, or the platform rejects the request as an
- // unknown tenant — 'socity_bank' was listed with no such row behind it.
+ // unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'default';
- window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank';
+ window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank';
window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = '';
diff --git a/src/environments/.env.ts b/src/environments/.env.ts
index ea6c18341..83bb12841 100644
--- a/src/environments/.env.ts
+++ b/src/environments/.env.ts
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260911',
- 'hash': 'fd5fe6e3'
+ 'hash': '7afa9c7a'
},
'allow_switching_backend_instance': true
};
+
diff --git a/src/app/platform-admin/menu-access/menu-access.component.scss b/src/app/platform-admin/menu-access/menu-access.component.scss
new file mode 100644
index 000000000..1e107b19b
--- /dev/null
+++ b/src/app/platform-admin/menu-access/menu-access.component.scss
@@ -0,0 +1,431 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// ── Design tokens ──────────────────────────────────────────────────────────
+$ink: #0f172a;
+$ink-soft: #475569;
+$ink-faint: #94a3b8;
+$border: #e2e8f0;
+$bg: #eef2f7;
+$card: #fff;
+$accent: #4338ca;
+$accent-soft: #eef2ff;
+$success: #16a34a;
+$success-soft: #f0fdf4;
+$danger: #dc2626;
+$danger-soft: #fef2f2;
+
+.pma-page {
+ min-height: 100vh;
+ width: 100%;
+ padding: 40px clamp(20px, 4vw, 64px) 80px;
+ background: radial-gradient(circle at 10% 0%, rgb(67 56 202 / 8%), transparent 45%), $bg;
+ font-family:
+ Inter,
+ system-ui,
+ -apple-system,
+ sans-serif;
+ box-sizing: border-box;
+}
+
+.pma-shell {
+ width: 100%;
+}
+
+// ── Header ────────────────────────────────────────────────────────────────
+.pma-head {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 24px;
+
+ &__badge {
+ flex: none;
+ width: 46px;
+ height: 46px;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: $ink;
+ color: #fff;
+ font-size: 18px;
+ box-shadow: 0 6px 16px rgb(15 23 42 / 25%);
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 21px;
+ font-weight: 800;
+ color: $ink;
+ letter-spacing: -0.01em;
+ }
+
+ &__sub {
+ margin: 2px 0 0;
+ font-size: 13px;
+ color: $ink-soft;
+ line-height: 1.5;
+ }
+}
+
+// ── Cards ─────────────────────────────────────────────────────────────────
+.pma-card {
+ background: $card;
+ border: 1px solid $border;
+ border-radius: 14px;
+ padding: 24px;
+ margin-bottom: 16px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+}
+
+// ── Connect block ─────────────────────────────────────────────────────────
+.pma-connect__field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+
+ label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: $ink-soft;
+ }
+}
+
+.pma-key-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ position: relative;
+ max-width: 640px;
+
+ input {
+ flex: 1;
+ height: 42px;
+ padding: 0 14px 0 38px;
+ border: 1.5px solid $border;
+ border-radius: 9px;
+ font-size: 14px;
+ font-family: ui-monospace, monospace;
+ color: $ink;
+ transition: border-color 0.15s;
+
+ &:focus {
+ outline: none;
+ border-color: $accent;
+ }
+ }
+}
+
+.pma-key-ic {
+ position: absolute;
+ left: 13px;
+ color: $ink-faint;
+ font-size: 13px;
+ pointer-events: none;
+}
+
+.pma-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 14px;
+ padding: 9px 14px;
+ border-radius: 8px;
+ font-size: 12.5px;
+ font-weight: 600;
+
+ &--ok {
+ background: $success-soft;
+ color: $success;
+ }
+}
+
+// ── Alerts ────────────────────────────────────────────────────────────────
+.pma-alert {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ border-radius: 10px;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 16px;
+
+ &--error {
+ background: $danger-soft;
+ color: $danger;
+ border: 1px solid #fecaca;
+ }
+
+ &--ok {
+ background: $success-soft;
+ color: $success;
+ border: 1px solid #bbf7d0;
+ }
+}
+
+// ── Field / select ────────────────────────────────────────────────────────
+.pma-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 18px;
+
+ label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: $ink-soft;
+ }
+
+ select {
+ height: 42px;
+ max-width: 480px;
+ padding: 0 14px;
+ border: 1.5px solid $border;
+ border-radius: 9px;
+ font-size: 14px;
+ font-weight: 600;
+ color: $ink;
+ background: #fff;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ border-color: $accent;
+ }
+ }
+}
+
+.pma-loading {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 32px 0;
+ justify-content: center;
+ font-size: 13.5px;
+ color: $ink-soft;
+}
+
+// ── Toolbar ───────────────────────────────────────────────────────────────
+.pma-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.pma-count {
+ display: flex;
+ align-items: baseline;
+ gap: 4px;
+
+ &__num {
+ font-size: 22px;
+ font-weight: 800;
+ color: $accent;
+ }
+
+ &__of {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: $ink-faint;
+ }
+}
+
+.pma-toolbar__actions {
+ display: flex;
+ gap: 8px;
+}
+
+// ── Buttons ───────────────────────────────────────────────────────────────
+.pma-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 40px;
+ padding: 0 18px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+ border: 1.5px solid transparent;
+ font-family: inherit;
+ transition: all 0.15s;
+ white-space: nowrap;
+
+ &--primary {
+ background: $ink;
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #1e293b;
+ }
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+ }
+
+ &--ghost {
+ background: #fff;
+ border-color: $border;
+ color: $ink-soft;
+ height: 34px;
+ padding: 0 13px;
+ font-size: 12.5px;
+
+ &:hover {
+ border-color: $accent;
+ color: $accent;
+ background: $accent-soft;
+ }
+ }
+}
+
+// ── Menu tile grid ────────────────────────────────────────────────────────
+.pma-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 10px;
+}
+
+.pma-tile {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 12px 14px;
+ border: 1.5px solid $border;
+ border-radius: 10px;
+ background: #fafbfc;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover {
+ border-color: #cbd5e1;
+ }
+
+ &--on {
+ background: $accent-soft;
+ border-color: #c7d2fe;
+ }
+}
+
+.pma-tile__ic {
+ flex: none;
+ width: 20px;
+ text-align: center;
+ font-size: 14px;
+ color: $ink-faint;
+
+ .pma-tile--on & {
+ color: $accent;
+ }
+}
+
+.pma-tile__label {
+ flex: 1;
+ font-size: 13.5px;
+ font-weight: 600;
+ color: $ink;
+ line-height: 1.25;
+}
+
+// Custom toggle switch — checkbox visually hidden, styled track+thumb driven by :checked.
+.pma-switch {
+ position: relative;
+ flex: none;
+ width: 36px;
+ height: 20px;
+
+ input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ margin: 0;
+ cursor: pointer;
+ z-index: 1;
+
+ &:checked ~ .pma-switch__track {
+ background: $accent;
+
+ .pma-switch__thumb {
+ transform: translateX(16px);
+ }
+ }
+ }
+}
+
+.pma-switch__track {
+ position: absolute;
+ inset: 0;
+ border-radius: 999px;
+ background: #cbd5e1;
+ transition: background 0.15s;
+}
+
+.pma-switch__thumb {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 25%);
+ transition: transform 0.15s;
+}
+
+// ── Save bar ──────────────────────────────────────────────────────────────
+.pma-save-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-top: 20px;
+ padding-top: 18px;
+ border-top: 1px solid $border;
+ flex-wrap: wrap;
+}
+
+.pma-save-bar__hint {
+ font-size: 12px;
+ color: $ink-faint;
+}
+
+.pma-save {
+ margin-left: auto;
+}
+
+// ── Responsive ────────────────────────────────────────────────────────────
+@media (width <= 560px) {
+ .pma-key-row {
+ flex-wrap: wrap;
+ }
+
+ .pma-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .pma-save-bar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .pma-save {
+ margin-left: 0;
+ justify-content: center;
+ }
+}
diff --git a/src/app/platform-admin/menu-access/menu-access.component.ts b/src/app/platform-admin/menu-access/menu-access.component.ts
new file mode 100644
index 000000000..e98bcfebd
--- /dev/null
+++ b/src/app/platform-admin/menu-access/menu-access.component.ts
@@ -0,0 +1,217 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { environment } from '../../../environments/environment';
+
+interface MenuRow {
+ code: string;
+ label: string;
+ icon: string;
+ enabled: boolean;
+}
+
+interface TenantOption {
+ identifier: string;
+ name: string;
+}
+
+/**
+ * Standalone platform-operator screen — deliberately outside the normal login/tenant-selector flow
+ * every other page in this app goes through. It talks to the backend with a hand-entered API key
+ * (`X-Api-Key`) instead of a Fineract session, exactly the credential `TenantMenuAccessApiResource`
+ * requires for its write side and accepts for its reads. Reachable at #/platform-admin/menu-access
+ * without logging in, on purpose: the real security boundary is the key, checked on the backend, not
+ * this page's URL — a bank's own super user has no way to obtain that key at all, and without it
+ * every call here 401s regardless of who is looking at the page.
+ *
+ * The key itself is minted once via `POST /v1/apiclients` (super-user session, one-time) and pasted
+ * in below; this page only remembers it in this browser's localStorage for convenience across visits,
+ * it is never sent anywhere but the two endpoints below.
+ */
+@Component({
+ selector: 'mifosx-platform-menu-access',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ FaIconComponent
+ ],
+ templateUrl: './menu-access.component.html',
+ styleUrl: './menu-access.component.scss'
+})
+export class PlatformMenuAccessComponent {
+ private static readonly KEY_STORAGE = 'cbsx_platform_api_key';
+
+ /** Every menu code TenantMenuAccessApiResource knows about, with the label and icon shown on this
+ * bank's own top nav — same icon names as shell.component.html, so a toggle here is recognisable
+ * as the exact item it controls. Kept in the same order as the nav's two rows. */
+ private static readonly ALL_MENUS: { code: string; label: string; icon: string }[] = [
+ { code: 'MENU_INSTITUTION', label: 'Client Master', icon: 'users-cog' },
+ { code: 'SIDEBAR_CFCFRMS', label: 'CFCFRMS', icon: 'user-shield' },
+ { code: 'MENU_TASKS', label: 'Approvals', icon: 'check' },
+ { code: 'MENU_CASHIER', label: 'Cashier', icon: 'money-bill' },
+ { code: 'MENU_CLERK', label: 'Clerk', icon: 'id-card' },
+ { code: 'MENU_BRANCH_OPS', label: 'Branch Ops', icon: 'building' },
+ { code: 'MENU_CLEARING', label: 'Clearing', icon: 'exchange-alt' },
+ { code: 'MENU_DEPOSITS', label: 'Deposits', icon: 'piggy-bank' },
+ { code: 'MENU_ADVANCES', label: 'Advances', icon: 'hand-holding-usd' },
+ { code: 'MENU_LOAN_ORIGINATION', label: 'Loan Origination', icon: 'clipboard-check' },
+ { code: 'MENU_SHARE', label: 'Share', icon: 'coins' },
+ { code: 'MENU_NPA', label: 'NPA', icon: 'exclamation-triangle' },
+ { code: 'MENU_GENERAL_LEDGER', label: 'General Ledger', icon: 'book-open' },
+ { code: 'MENU_SUB_LEDGER', label: 'Sub Ledger', icon: 'book' },
+ { code: 'MENU_ACCOUNTING', label: 'Accounting', icon: 'money-bill-alt' },
+ { code: 'MENU_CKYC_MGMT', label: 'CKYC', icon: 'fingerprint' },
+ { code: 'MENU_INSURANCE', label: 'Insurance', icon: 'shield-alt' },
+ { code: 'MENU_DIGITAL', label: 'Digital', icon: 'mobile-alt' },
+ { code: 'MENU_HRM', label: 'HRM', icon: 'money-check' },
+ { code: 'MENU_ADMIN', label: 'Admin', icon: 'shield-alt' },
+ { code: 'MENU_RBI', label: 'RBI', icon: 'university' },
+ { code: 'MENU_INVESTMENT', label: 'Investment', icon: 'chart-line' },
+ { code: 'MENU_REPORTS', label: 'Reports', icon: 'chart-bar' },
+ { code: 'MENU_OTHERS', label: 'Others', icon: 'folder-open' },
+ { code: 'MENU_CONFIG_WIZARD', label: 'Reconciliation', icon: 'sync-alt' },
+ { code: 'MENU_CHEQUEBOOK', label: 'Cheque Book', icon: 'book' },
+ { code: 'MENU_LETTER_MGMT', label: 'Documents Hub', icon: 'envelope' }
+ ];
+
+ apiKey = '';
+ tenants: TenantOption[] = [];
+ selectedTenant = '';
+ rows: MenuRow[] = [];
+
+ loadingTenants = false;
+ loadingMenus = false;
+ saving = false;
+ error: string | null = null;
+ savedMessage: string | null = null;
+
+ constructor() {
+ this.apiKey = localStorage.getItem(PlatformMenuAccessComponent.KEY_STORAGE) ?? '';
+ }
+
+ private headers(tenantForFilter?: string): Record
+
+
+
+ 0">
+
+
+ @if (error) {
+
+
+ }
+
+
+
+
+
+
+ Bank Feature Access
++ Platform-level control — caps which top-nav sections a bank can ever show, independent of any role's own + permissions. +
+
+
+
+
+ @if (tenants.length > 0) {
+
+
+
+
+
+
+
+ Connected — {{ tenants.length }} bank{{ tenants.length !== 1 ? 's' : '' }} on this platform
+
+ }
+
+
+ {{ error }}
+
+ }
+ @if (savedMessage) {
+
+
+ {{ savedMessage }}
+
+ }
+
+ @if (tenants.length > 0) {
+
+
+
+
+
+ @if (loadingMenus) {
+
+
+ Loading {{ selectedTenantName }}'s menu list…
+
+ }
+
+ @if (!loadingMenus && rows.length > 0) {
+
+
+
+
+ {{ enabledCount }}
+ / {{ rows.length }} shown
+
+
+
+
+
+
+ @for (row of rows; track row.code) {
+
+ }
+
+
+
+ Changes apply the moment {{ selectedTenantName }}'s users next load the app.
+
+
+ }
+