Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

# Conflicts:
#	src/environments/.env.ts
This commit is contained in:
2026-09-14 11:44:42 +05:30
9 changed files with 881 additions and 5 deletions
+7
View File
@@ -13,6 +13,7 @@ import { Routes, RouterModule } from '@angular/router';
// Not Found Component
import { NotFoundComponent } from './not-found/not-found.component';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { PlatformMenuAccessComponent } from './platform-admin/menu-access/menu-access.component';
/**
* Fallback to this route when no prior route is matched.
@@ -22,6 +23,12 @@ const routes: Routes = [
path: 'callback',
component: CallbackComponent
},
{
// Platform-operator only — outside the normal login/tenant flow on purpose, see
// PlatformMenuAccessComponent's own doc comment for why that is safe.
path: 'platform-admin/menu-access',
component: PlatformMenuAccessComponent
},
{
path: '**',
component: NotFoundComponent
+8 -3
View File
@@ -473,7 +473,7 @@
════════ END OLD ORDER ════════
-->
@if (!hideShellLayout) {
@if (!hideShellLayout && menuAccessReady) {
<div class="main-header">
<!-- Row 1 — Client Master · CFCFRMS · Approvals · Cashier · Clearing · Deposits · Advances · Share · NPA · General Ledger · Sub Ledger -->
<div class="nav-row r1">
@@ -524,7 +524,12 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<a
class="ni"
tabindex="0"
[matMenuTriggerFor]="loanOriginationMenu"
*mifosxHasPermission="'MENU_LOAN_ORIGINATION'"
>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
@@ -586,7 +591,7 @@
class="ni"
tabindex="0"
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
*mifosxHasPermission="'MENU_RBI'"
*mifosxHasPermission="'MENU_INVESTMENT'"
>
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
+11
View File
@@ -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<boolean> = 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;
@@ -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<any>>(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;
@@ -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<string>();
/** 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([]);
})
);
}
}
@@ -0,0 +1,135 @@
<!--
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/.
-->
<div class="pma-page">
<div class="pma-shell">
<header class="pma-head">
<div class="pma-head__badge">
<fa-icon icon="shield-alt"></fa-icon>
</div>
<div>
<h1 class="pma-head__title">Bank Feature Access</h1>
<p class="pma-head__sub">
Platform-level control &mdash; caps which top-nav sections a bank can ever show, independent of any role's own
permissions.
</p>
</div>
</header>
<section class="pma-card pma-connect" [class.pma-connect--done]="tenants.length > 0">
<div class="pma-connect__field">
<label for="pma-key">Platform API Key</label>
<div class="pma-key-row">
<fa-icon icon="key" class="pma-key-ic"></fa-icon>
<input
id="pma-key"
type="password"
[(ngModel)]="apiKey"
placeholder="cbsx_••••••••••••••••••••••••"
(keyup.enter)="loadTenants()"
/>
<button type="button" class="pma-btn pma-btn--primary" [disabled]="loadingTenants" (click)="loadTenants()">
@if (loadingTenants) {
<fa-icon icon="spinner" [spin]="true"></fa-icon>
} @else if (tenants.length > 0) {
<fa-icon icon="sync-alt"></fa-icon>
} @else {
<fa-icon icon="sign-in-alt"></fa-icon>
}
{{ tenants.length > 0 ? 'Refresh' : 'Connect' }}
</button>
</div>
</div>
@if (tenants.length > 0) {
<div class="pma-status pma-status--ok">
<fa-icon icon="check-circle"></fa-icon>
Connected &mdash; {{ tenants.length }} bank{{ tenants.length !== 1 ? 's' : '' }} on this platform
</div>
}
</section>
@if (error) {
<div class="pma-alert pma-alert--error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (savedMessage) {
<div class="pma-alert pma-alert--ok">
<fa-icon icon="check-circle"></fa-icon>
<span>{{ savedMessage }}</span>
</div>
}
@if (tenants.length > 0) {
<section class="pma-card">
<div class="pma-field">
<label for="pma-tenant">Bank</label>
<select id="pma-tenant" [(ngModel)]="selectedTenant" (ngModelChange)="loadMenusForSelectedTenant()">
@for (t of tenants; track t.identifier) {
<option [value]="t.identifier">{{ t.name }} &middot; {{ t.identifier }}</option>
}
</select>
</div>
@if (loadingMenus) {
<div class="pma-loading">
<fa-icon icon="spinner" [spin]="true"></fa-icon>
Loading {{ selectedTenantName }}'s menu list&hellip;
</div>
}
@if (!loadingMenus && rows.length > 0) {
<div class="pma-toolbar">
<div class="pma-count">
<span class="pma-count__num">{{ enabledCount }}</span>
<span class="pma-count__of">/ {{ rows.length }} shown</span>
</div>
<div class="pma-toolbar__actions">
<button type="button" class="pma-btn pma-btn--ghost" (click)="setAll(true)">
<fa-icon icon="eye"></fa-icon> Select all
</button>
<button type="button" class="pma-btn pma-btn--ghost" (click)="setAll(false)">
<fa-icon icon="eye-slash"></fa-icon> Clear all
</button>
</div>
</div>
<div class="pma-grid">
@for (row of rows; track row.code) {
<label class="pma-tile" [class.pma-tile--on]="row.enabled">
<fa-icon [icon]="row.icon" class="pma-tile__ic"></fa-icon>
<span class="pma-tile__label">{{ row.label }}</span>
<span class="pma-switch">
<input type="checkbox" [(ngModel)]="row.enabled" />
<span class="pma-switch__track"><span class="pma-switch__thumb"></span></span>
</span>
</label>
}
</div>
<div class="pma-save-bar">
<span class="pma-save-bar__hint"
>Changes apply the moment {{ selectedTenantName }}'s users next load the app.</span
>
<button type="button" class="pma-btn pma-btn--primary pma-save" [disabled]="saving" (click)="save()">
@if (saving) {
<fa-icon icon="spinner" [spin]="true"></fa-icon>
Saving&hellip;
} @else {
<fa-icon icon="save"></fa-icon>
Save for {{ selectedTenantName }}
}
</button>
</div>
}
</section>
}
</div>
</div>
@@ -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;
}
}
@@ -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;
}
}
+2 -2
View File
@@ -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'] = '';