Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
# Conflicts: # src/environments/.env.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 — 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 — {{ 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 }} · {{ t.identifier }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (loadingMenus) {
|
||||
<div class="pma-loading">
|
||||
<fa-icon icon="spinner" [spin]="true"></fa-icon>
|
||||
Loading {{ selectedTenantName }}'s menu list…
|
||||
</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…
|
||||
} @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
@@ -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'] = '';
|
||||
|
||||
Reference in New Issue
Block a user