feat(security): tenant-level nav menu access control panel
Adds the frontend half of the per-tenant menu-access ceiling (TenantMenuAccessApiResource on the backend): a bank cannot show a top-nav section the platform operator has capped, no matter what any role inside that tenant grants itself — closing the gap where a role carrying ALL_FUNCTIONS (as "Clerk" was found to, in two tenants this session) bypassed every ordinary MENU_.../SIDEBAR_... permission check. - tenant-menu-access.service.ts: loads the current tenant's list once, into a plain Set (not an Observable the directive would have to subscribe to, since mifosxHasPermission's setter runs synchronously). A failed load is treated as "nothing disabled" so a network hiccup on login never hides the whole nav. - has-permission.directive.ts: checks the tenant's ceiling first, ahead of the ALL_FUNCTIONS bypass — that ordering is the actual fix. - shell.component.ts/html: the nav now waits on the list being loaded before rendering at all, so a disabled menu is never shown and then hidden a moment later. - New standalone platform-admin/menu-access screen (#/platform-admin/ menu-access), reachable without logging in — its own X-Api-Key is the real boundary, checked on the backend, not this route. Lets the operator pick any tenant and toggle its menu list. Redesigned per request: full-width layout, icon-matched toggle tiles (same icon per item as the real nav), live enabled-count, connection status. - Loan Origination and Investment now use their own MENU_LOAN_ORIGINATION / MENU_INVESTMENT codes (see backend 0542) instead of sharing MENU_ADVANCES / MENU_RBI, so all 27 nav items are independently controllable from this screen. Also: src/assets/env.js — restored 'socity_bank' to fineractPlatformTenantIds. The tenant now exists in the tenant-store database; the comment explaining its earlier removal was stale.
This commit is contained in:
@@ -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<string, string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user