/** * 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 { 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 || environment.fineractPlatformTenantId }; } 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(environment.fineractPlatformTenantId) }); 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; } }