From d334dd1669ac67bc2d934387693b15f957ebacba Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 24 Jul 2026 16:20:46 +0530 Subject: [PATCH] feat(users): OTP delivery method picker on user creation + post-login agreement Create User dialog now has a mandatory Email/Mobile OTP delivery method picker (styled as a pill toggle) alongside the existing fields. Adds a new post-login agreement acceptance dialog: right after the existing 2FA flow (TwoFactorAuthenticationComponent) succeeds, or immediately after login for users without 2FA, the user must accept the bank's current agreement/disclaimer at least once before reaching the app. Also adds a "Manage User Agreement" admin screen (System section) to publish versioned agreement text without a code deploy. Co-Authored-By: Claude Sonnet 5 --- .../authentication/authentication.service.ts | 64 ++- .../agreement-accept.component.html | 71 +++ .../agreement-accept.component.scss | 221 +++++++++ .../agreement-accept.component.ts | 91 ++++ src/app/login/login.component.html | 4 +- src/app/login/login.component.ts | 38 ++ .../manage-user-agreement.component.html | 126 +++++ .../manage-user-agreement.component.scss | 439 ++++++++++++++++++ .../manage-user-agreement.component.ts | 135 ++++++ .../user-agreement.service.ts | 41 ++ src/app/system/system-routing.module.ts | 11 + src/app/system/system.component.html | 14 + .../create-user/create-user.component.html | 13 + .../create-user/create-user.component.scss | 8 + .../create-user/create-user.component.ts | 9 +- .../user-form-dialog.component.html | 21 + .../user-form-dialog.component.scss | 57 +++ .../user-form-dialog.component.ts | 9 +- 18 files changed, 1356 insertions(+), 16 deletions(-) create mode 100644 src/app/login/agreement-accept/agreement-accept.component.html create mode 100644 src/app/login/agreement-accept/agreement-accept.component.scss create mode 100644 src/app/login/agreement-accept/agreement-accept.component.ts create mode 100644 src/app/system/manage-user-agreement/manage-user-agreement.component.html create mode 100644 src/app/system/manage-user-agreement/manage-user-agreement.component.scss create mode 100644 src/app/system/manage-user-agreement/manage-user-agreement.component.ts create mode 100644 src/app/system/manage-user-agreement/user-agreement.service.ts diff --git a/src/app/core/authentication/authentication.service.ts b/src/app/core/authentication/authentication.service.ts index 57e5f30fc..5e6bcbe05 100644 --- a/src/app/core/authentication/authentication.service.ts +++ b/src/app/core/authentication/authentication.service.ts @@ -281,12 +281,8 @@ export class AuthenticationService { message: 'Your password has expired, please reset your password!' }); } else { - this.setCredentials(credentials); - this.alertService.alert({ - type: 'Authentication Success', - message: `${credentials.username} successfully logged in!` - }); - delete this.credentials; + this.credentials = credentials; + this.finalizeLogin(); } } } @@ -546,16 +542,60 @@ export class AuthenticationService { message: 'Your password has expired, please reset your password!' }); } else { - this.setCredentials(this.credentials); - this.alertService.alert({ - type: 'Authentication Success', - message: `${this.credentials.username} successfully logged in!` - }); - delete this.credentials; this.storage.setItem(this.twoFactorAuthenticationTokenStorageKey, JSON.stringify(response)); + this.finalizeLogin(); } } + /** + * Runs after primary login (and 2FA, if the account has it enrolled) succeeds. Every user must + * accept the bank's current agreement/disclaimer at least once — checks that here and either + * finishes the login immediately (already accepted, or nothing published yet) or fires an alert + * so `login.component.ts` can show the accept dialog. + */ + private finalizeLogin(): void { + this.http.get<{ accepted: boolean }>('/useragreements/active/status').subscribe({ + next: (status) => { + if (status?.accepted) { + this.completeAuthentication(); + } else { + this.alertService.alert({ + type: 'Agreement Acceptance Required', + message: 'Please review and accept the agreement to continue.' + }); + } + }, + // Don't let an agreement-status hiccup block login entirely. + error: () => this.completeAuthentication() + }); + } + + /** Finishes the login: persists credentials and fires the existing 'Authentication Success' alert. */ + private completeAuthentication(): void { + this.setCredentials(this.credentials); + this.alertService.alert({ + type: 'Authentication Success', + message: `${this.credentials.username} successfully logged in!` + }); + delete this.credentials; + } + + /** Fetches the active agreement's text for display, when the current user hasn't accepted it yet. */ + getAgreementStatus(): Observable<{ + accepted: boolean; + id?: number; + version?: number; + title?: string; + body?: string; + }> { + return this.http.get('/useragreements/active/status'); + } + + /** Records the current user's acceptance of the active agreement, then finishes the login. */ + acceptActiveAgreement(): Observable { + return this.http.post('/useragreements/active/accept', {}).pipe(map(() => this.completeAuthentication())); + } + /** * Resets the user's password and authenticates the user. * @param {any} passwordDetails New password. diff --git a/src/app/login/agreement-accept/agreement-accept.component.html b/src/app/login/agreement-accept/agreement-accept.component.html new file mode 100644 index 000000000..1112db490 --- /dev/null +++ b/src/app/login/agreement-accept/agreement-accept.component.html @@ -0,0 +1,71 @@ + + +
+
+ +
+ +

Terms & Agreement

+

+ @if (loading) { + Loading the agreement… + } @else if (loadErrorMessage) { + We couldn't load the agreement. + } @else { + Please read the agreement below before continuing. + } +

+ + @if (loading) { +
+ +
+ } @else if (loadErrorMessage) { +
+ + {{ loadErrorMessage }} +
+ + } @else { +
+

{{ agreementTitle }}

+
{{ agreementBody }}
+
+ + + + @if (submitErrorMessage) { +
+ + {{ submitErrorMessage }} +
+ } + + + } +
diff --git a/src/app/login/agreement-accept/agreement-accept.component.scss b/src/app/login/agreement-accept/agreement-accept.component.scss new file mode 100644 index 000000000..dfa460d01 --- /dev/null +++ b/src/app/login/agreement-accept/agreement-accept.component.scss @@ -0,0 +1,221 @@ +/** + * 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/. + */ + +:host { + display: flex; + width: 100%; + justify-content: center; + animation: aga-fade 0.4s ease-out; +} + +.aga-card { + width: 100%; + max-width: 460px; + padding: 20px 24px 24px; + border-radius: 20px; + background: linear-gradient(180deg, rgb(255 255 255 / 7%) 0%, rgb(255 255 255 / 4%) 100%); + border: 1px solid rgb(255 255 255 / 14%); + backdrop-filter: blur(12px); + display: flex; + flex-direction: column; + align-items: stretch; + box-shadow: + 0 30px 60px rgb(0 0 0 / 35%), + 0 10px 24px rgb(0 0 0 / 22%), + inset 0 1px 0 rgb(255 255 255 / 8%); + position: relative; + overflow: hidden; + isolation: isolate; +} + +.aga-icon { + align-self: center; + width: 60px; + height: 60px; + border-radius: 18px; + background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 26px; + box-shadow: + 0 10px 28px rgb(37 99 235 / 45%), + 0 0 0 1px rgb(255 255 255 / 10%), + inset 0 1px 0 rgb(255 255 255 / 25%); + margin-bottom: 14px; +} + +.aga-title { + margin: 0 0 6px; + text-align: center; + font-size: 19px; + font-weight: 700; + color: #fff; + letter-spacing: 0.2px; +} + +.aga-sub { + margin: 0 0 18px; + text-align: center; + font-size: 13px; + color: rgb(255 255 255 / 65%); +} + +.aga-loading { + display: flex; + justify-content: center; + padding: 20px 0; +} + +.aga-agreement { + max-height: 260px; + overflow-y: auto; + padding: 14px 16px; + margin-bottom: 14px; + border-radius: 12px; + background: rgb(255 255 255 / 5%); + border: 1px solid rgb(255 255 255 / 10%); + + &__title { + margin: 0 0 8px; + font-size: 14px; + font-weight: 700; + color: #fff; + } + + &__body { + font-size: 13px; + line-height: 1.6; + color: rgb(255 255 255 / 78%); + white-space: pre-wrap; + } +} + +.aga-check { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 16px; + font-size: 13px; + color: rgb(255 255 255 / 80%); + cursor: pointer; + + // stylelint-disable-next-line selector-no-qualifying-type + input[type='checkbox'] { + margin-top: 2px; + width: 16px; + height: 16px; + flex-shrink: 0; + accent-color: #2563eb; + } +} + +.aga-error { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + margin-bottom: 14px; + border-radius: 10px; + background: rgb(239 68 68 / 14%); + border: 1px solid rgb(239 68 68 / 35%); + color: #fecaca; + font-size: 12.5px; + + fa-icon { + font-size: 13px; + color: #fca5a5; + } +} + +.aga-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: 48px; + border-radius: 12px; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; + cursor: pointer; + transition: + background 0.18s, + box-shadow 0.18s, + transform 0.18s, + opacity 0.18s; + border: 1px solid transparent; + + fa-icon { + font-size: 14px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &--primary { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 60%, #1e3a8a 100%); + color: #fff; + box-shadow: + 0 10px 24px rgb(37 99 235 / 38%), + 0 0 0 1px rgb(255 255 255 / 6%), + inset 0 1px 0 rgb(255 255 255 / 22%); + + fa-icon { + filter: drop-shadow(0 1px 2px rgb(0 0 0 / 25%)); + } + + &:hover:not(:disabled) { + transform: translateY(-1px); + background: linear-gradient(135deg, #4f8ff7 0%, #2563eb 60%, #1e3a8a 100%); + box-shadow: + 0 14px 28px rgb(37 99 235 / 48%), + 0 0 0 1px rgb(255 255 255 / 8%), + inset 0 1px 0 rgb(255 255 255 / 26%); + } + } + + &--ghost { + background: transparent; + color: rgb(255 255 255 / 80%); + border-color: rgb(255 255 255 / 18%); + + &:hover:not(:disabled) { + background: rgb(255 255 255 / 8%); + color: #fff; + border-color: rgb(255 255 255 / 30%); + } + } + + mat-spinner ::ng-deep circle { + stroke: #fff !important; + } +} + +@keyframes aga-fade { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (width <= 480px) { + .aga-card { + padding: 18px 16px 20px; + } +} diff --git a/src/app/login/agreement-accept/agreement-accept.component.ts b/src/app/login/agreement-accept/agreement-accept.component.ts new file mode 100644 index 000000000..c342e91c3 --- /dev/null +++ b/src/app/login/agreement-accept/agreement-accept.component.ts @@ -0,0 +1,91 @@ +/** + * 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/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** rxjs Imports */ +import { finalize } from 'rxjs/operators'; + +/** Custom Services */ +import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Shown right after a successful login (OTP included, if the account has it enrolled) when the + * signed-in user hasn't yet accepted the bank's current agreement/disclaimer. Non-dismissable — + * mirrors TwoFactorAuthenticationComponent's dialog posture. + */ +@Component({ + selector: 'mifosx-agreement-accept', + templateUrl: './agreement-accept.component.html', + styleUrls: ['./agreement-accept.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatProgressSpinner, + FaIconComponent + ] +}) +export class AgreementAcceptComponent implements OnInit { + private authenticationService = inject(AuthenticationService); + public dialogRef = inject(MatDialogRef); + + loading = true; + loadErrorMessage = ''; + + agreementTitle = ''; + agreementBody = ''; + accepted = false; + + submitting = false; + submitErrorMessage = ''; + + ngOnInit(): void { + this.loadAgreement(); + } + + loadAgreement(): void { + this.loading = true; + this.loadErrorMessage = ''; + this.authenticationService + .getAgreementStatus() + .pipe(finalize(() => (this.loading = false))) + .subscribe({ + next: (status) => { + this.agreementTitle = status?.title || ''; + this.agreementBody = status?.body || ''; + }, + error: (err) => { + this.loadErrorMessage = + err?.error?.defaultUserMessage || err?.error?.message || 'Could not load the agreement.'; + } + }); + } + + toggleAccepted(): void { + this.accepted = !this.accepted; + } + + acceptAndContinue(): void { + if (!this.accepted || this.submitting) return; + this.submitErrorMessage = ''; + this.submitting = true; + this.authenticationService + .acceptActiveAgreement() + .pipe(finalize(() => (this.submitting = false))) + .subscribe({ + error: (err) => { + this.submitErrorMessage = + err?.error?.defaultUserMessage || err?.error?.message || 'Could not record your acceptance.'; + } + }); + } +} diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html index 8daacd944..39c2946de 100644 --- a/src/app/login/login.component.html +++ b/src/app/login/login.component.html @@ -247,13 +247,13 @@ - @if (!resetPassword && !twoFactorAuthenticationRequired) { + @if (!resetPassword && !twoFactorAuthenticationRequired && !agreementAcceptanceRequired) { } @if (resetPassword) { } - + diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts index 807722d89..4bf3298d2 100644 --- a/src/app/login/login.component.ts +++ b/src/app/login/login.component.ts @@ -43,6 +43,7 @@ import { TenantSelectorComponent } from '../shared/tenant-selector/tenant-select import { LoginFormComponent } from './login-form/login-form.component'; import { ResetPasswordComponent } from './reset-password/reset-password.component'; import { TwoFactorAuthenticationComponent } from './two-factor-authentication/two-factor-authentication.component'; +import { AgreementAcceptComponent } from './agreement-accept/agreement-accept.component'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -85,6 +86,7 @@ import { AuthenticationService } from '../core/authentication/authentication.ser LoginFormComponent, ResetPasswordComponent, TwoFactorAuthenticationComponent, + AgreementAcceptComponent, MatMenuTrigger, FaIconComponent, MatMenu, @@ -220,6 +222,7 @@ export class LoginComponent implements OnInit, OnDestroy { private themingService = inject(ThemingService); private dialog = inject(MatDialog); private twoFactorDialogRef: MatDialogRef | null = null; + private agreementAcceptDialogRef: MatDialogRef | null = null; private router = inject(Router); private authService = inject(AuthenticationService); @@ -245,6 +248,8 @@ export class LoginComponent implements OnInit, OnDestroy { resetPassword = false; /** True if user requires two factor authentication. */ twoFactorAuthenticationRequired = false; + /** True if the signed-in user still needs to accept the current agreement/disclaimer. */ + agreementAcceptanceRequired = false; /** Subscription to alerts. */ alert$: Subscription; logoPath = 'assets/images/cbsnexora-logo.png'; @@ -275,16 +280,24 @@ export class LoginComponent implements OnInit, OnDestroy { const alertType = alertEvent.type; if (alertType === 'Password Expired') { this.twoFactorAuthenticationRequired = false; + this.agreementAcceptanceRequired = false; this.closeTwoFactorDialog(); + this.closeAgreementAcceptDialog(); this.resetPassword = true; } else if (alertType === 'Two Factor Authentication Required') { this.resetPassword = false; this.twoFactorAuthenticationRequired = true; this.openTwoFactorDialog(); + } else if (alertType === 'Agreement Acceptance Required') { + this.resetPassword = false; + this.agreementAcceptanceRequired = true; + this.openAgreementAcceptDialog(); } else if (alertType === 'Authentication Success') { this.resetPassword = false; this.twoFactorAuthenticationRequired = false; + this.agreementAcceptanceRequired = false; this.closeTwoFactorDialog(); + this.closeAgreementAcceptDialog(); // Cashier users go directly to the full-screen transaction entry dashboard const destination = this.authService.isCashierUser() ? ['/cashier/transaction-entry'] : ['/']; this.router.navigate(destination, { replaceUrl: true }); @@ -334,6 +347,7 @@ export class LoginComponent implements OnInit, OnDestroy { this.theme$.unsubscribe(); } this.closeTwoFactorDialog(); + this.closeAgreementAcceptDialog(); clearInterval(this.slideTimer); } @@ -361,6 +375,30 @@ export class LoginComponent implements OnInit, OnDestroy { } } + /** Open the post-login agreement acceptance dialog as a non-dismissable modal */ + private openAgreementAcceptDialog(): void { + if (this.agreementAcceptDialogRef) return; + this.agreementAcceptDialogRef = this.dialog.open(AgreementAcceptComponent, { + width: '460px', + maxWidth: '95vw', + panelClass: 'tfa-dialog-panel', + backdropClass: 'tfa-dialog-backdrop', + disableClose: true, + autoFocus: 'first-tabbable', + restoreFocus: true + }); + this.agreementAcceptDialogRef.afterClosed().subscribe(() => { + this.agreementAcceptDialogRef = null; + }); + } + + private closeAgreementAcceptDialog(): void { + if (this.agreementAcceptDialogRef) { + this.agreementAcceptDialogRef.close(); + this.agreementAcceptDialogRef = null; + } + } + goToSlide(index: number): void { this.currentSlide = index; this.restartSlider(); diff --git a/src/app/system/manage-user-agreement/manage-user-agreement.component.html b/src/app/system/manage-user-agreement/manage-user-agreement.component.html new file mode 100644 index 000000000..e069bcc6c --- /dev/null +++ b/src/app/system/manage-user-agreement/manage-user-agreement.component.html @@ -0,0 +1,126 @@ + +
+ +
+ +
+
+
COMPLIANCE · CONFIGURATION
+

Manage user agreement

+

+ Publish versioned agreement / warning text that new users must read and accept on their first login. +

+
+ +
+ + @if (loading) { +
+ hourglass_empty + Loading… +
+ } @else if (agreements.length === 0) { +
+ description + No agreement has been published yet. +
+ } @else { +
+
+
+ Version + Title + Published by + Published on + Status +
+ @for (item of agreements; track item.id) { +
+ v{{ item.version }} + {{ item.title }} + {{ item.createdByUsername || '—' }} + {{ item.createdOn || '—' }} + + + {{ isActive(item) ? 'Active' : 'Superseded' }} + + +
+ } +
+
+ } +
+ + +
+ + + +
diff --git a/src/app/system/manage-user-agreement/manage-user-agreement.component.scss b/src/app/system/manage-user-agreement/manage-user-agreement.component.scss new file mode 100644 index 000000000..f00100ea4 --- /dev/null +++ b/src/app/system/manage-user-agreement/manage-user-agreement.component.scss @@ -0,0 +1,439 @@ +/** + * 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 url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +:host { + /* Colors — matches denomination-master's palette for visual consistency across /system */ + --bg-page: #faf8f4; + --bg-card: #fff; + --bg-soft: #f2ede0; + --border: #ece6d8; + --border-strong: #e5e0d6; + --text-primary: #1a1a1a; + --text-secondary: #5f5c57; + --text-muted: #8a867f; + --accent-dark: #1a1a1a; + --accent-eyebrow: #0f4f3c; + --status-active-bg: #e8f3e8; + --status-active-text: #1f6f3d; + --status-inactive-bg: #f4ede0; + --status-inactive-text: #8a6a2e; + --r-sm: 8px; + --r-md: 10px; + --r-lg: 14px; + --r-xl: 16px; + --r-pill: 999px; + + display: block; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +.ua-page { + position: relative; + min-height: 100%; + background: var(--bg-page); + padding: 36px 40px; + overflow-x: clip; +} + +.ua-content { + width: 100%; +} + +/* ---------- HERO ---------- */ +.hero { + margin-bottom: 32px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +.hero-text { + min-width: 0; +} + +.hero-add-btn { + flex: 0 0 auto; + align-self: flex-start; + width: auto; + height: 38px; + margin-top: 4px; + padding: 0 16px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-family: inherit; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; + border: none; + border-radius: var(--r-pill); + cursor: pointer; + color: #fff; + background: linear-gradient(135deg, #1f7a4d 0%, #0f4f3c 100%); + box-shadow: 0 4px 14px -4px rgb(15 79 60 / 45%); + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + filter 0.15s ease; +} + +.hero-add-btn:hover { + transform: translateY(-1px); + box-shadow: 0 8px 22px -6px rgb(15 79 60 / 55%); + filter: brightness(1.06); +} + +.hero-add-btn mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + line-height: 17px; +} + +.eyebrow { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + color: var(--accent-eyebrow); + margin-bottom: 12px; +} + +.hero-title { + font-size: 34px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-primary); + margin: 0 0 12px; + line-height: 1.1; +} + +.hero-sub { + font-size: 15px; + color: var(--text-secondary); + max-width: 560px; + line-height: 1.5; + margin: 0; +} + +/* ---------- LOADING / EMPTY ---------- */ +.ua-loading, +.ua-empty { + display: flex; + align-items: center; + gap: 10px; + padding: 40px; + color: var(--text-muted); + font-size: 14px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--r-xl); +} + +/* ---------- TABLE ---------- */ +.ua-section { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--r-xl); + overflow: hidden; +} + +.ua-table { + display: flex; + flex-direction: column; +} + +.ua-row { + display: grid; + grid-template-columns: 90px 2fr 1fr 1fr 130px; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + + &:last-child { + border-bottom: none; + } + + &--head { + background: var(--bg-soft); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + } +} + +.ua-col { + min-width: 0; + font-size: 13.5px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &--version { + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + &--title { + font-weight: 500; + } + + &--author, + &--date { + color: var(--text-secondary); + } +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + padding: 3px 9px; + border-radius: var(--r-pill); + line-height: 1.4; +} + +.status-pill.is-active { + background: var(--status-active-bg); + color: var(--status-active-text); +} + +.status-pill.is-inactive { + background: var(--status-inactive-bg); + color: var(--status-inactive-text); +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentcolor; + display: inline-block; + flex: 0 0 auto; +} + +/* ---------- DRAWER ---------- */ +.drawer-backdrop { + position: fixed; + inset: 0; + background: rgb(26 26 26 / 18%); + z-index: 1000; + animation: fade-in 0.18s ease; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 460px; + background: var(--bg-card); + border-left: 1px solid var(--border); + padding: 32px 28px; + box-shadow: -20px 0 40px -20px rgb(0 0 0 / 18%); + overflow-y: auto; + z-index: 1001; + transform: translateX(100%); + transition: transform 0.24s cubic-bezier(0.4, 0, 0.2, 1); +} + +.drawer.open { + transform: translateX(0); +} + +.drawer-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.drawer-title { + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.drawer-sub { + font-size: 13px; + color: var(--text-secondary); + margin: 4px 0 0; +} + +.icon-btn { + width: 32px; + height: 32px; + border: 1px solid var(--border); + background: var(--bg-page); + color: var(--text-secondary); + border-radius: var(--r-sm); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + cursor: pointer; + padding: 0; + transition: + background 0.12s ease, + color 0.12s ease; +} + +.icon-btn:hover { + background: var(--bg-soft); + color: var(--text-primary); +} + +.icon-btn mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} + +.drawer-form { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 24px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field-label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +.text-input, +.text-area { + box-sizing: border-box; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--r-md); + background: var(--bg-page); + font-size: 14px; + color: var(--text-primary); + font-family: inherit; + width: 100%; +} + +.text-input { + height: 42px; +} + +.text-area { + padding: 12px 14px; + resize: vertical; + min-height: 160px; + line-height: 1.5; +} + +.text-input:focus, +.text-area:focus { + outline: none; + border-color: var(--accent-dark); + background: var(--bg-card); +} + +.text-input::placeholder, +.text-area::placeholder { + color: var(--text-muted); +} + +/* drawer actions */ +.drawer-actions { + display: flex; + gap: 10px; + margin-top: 8px; +} + +.btn { + flex: 1; + height: 42px; + padding: 0 18px; + border-radius: var(--r-pill); + font-size: 14px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: none; +} + +.btn-cancel { + background: var(--bg-card); + border: 1px solid var(--border-strong); + color: var(--text-primary); +} + +.btn-save { + background: var(--accent-dark); + border: 1px solid var(--accent-dark); + color: var(--bg-page); +} + +.btn-save:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* ---------- RESPONSIVE ---------- */ +@media (width <= 1100px) { + .drawer { + width: min(460px, 90vw); + } +} + +@media (width <= 820px) { + .hero { + flex-direction: column; + } + + .ua-row { + grid-template-columns: 70px 2fr 1fr 110px; + } + + .ua-col--author { + display: none; + } +} diff --git a/src/app/system/manage-user-agreement/manage-user-agreement.component.ts b/src/app/system/manage-user-agreement/manage-user-agreement.component.ts new file mode 100644 index 000000000..d658defde --- /dev/null +++ b/src/app/system/manage-user-agreement/manage-user-agreement.component.ts @@ -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/. + */ +import { Component, OnInit, HostListener, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatIconModule } from '@angular/material/icon'; +import { forkJoin } from 'rxjs'; +import { UserAgreementService, UserAgreement } from './user-agreement.service'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive'; + +@Component({ + selector: 'mifosx-manage-user-agreement', + templateUrl: './manage-user-agreement.component.html', + styleUrls: ['./manage-user-agreement.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatSnackBarModule, + MatIconModule, + HasPermissionDirective + ] +}) +export class ManageUserAgreementComponent implements OnInit { + private service = inject(UserAgreementService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + agreements: UserAgreement[] = []; + activeId: number | null = null; + loading = true; + + drawerOpen = false; + saving = false; + + model = { + title: '', + body: '' + }; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + forkJoin({ + all: this.service.getAll(), + active: this.service.getActive() + }).subscribe({ + next: ({ all, active }) => { + this.agreements = (all || []).slice().sort((a, b) => b.version - a.version); + this.activeId = active?.id ?? null; + this.loading = false; + }, + error: () => { + this.loading = false; + } + }); + } + + isActive(item: UserAgreement): boolean { + return item.isActive === true || (this.activeId != null && item.id === this.activeId); + } + + // ---- drawer ---- + openAdd(): void { + this.model = { title: '', body: '' }; + this.drawerOpen = true; + } + + closeDrawer(): void { + if (this.saving) return; + this.drawerOpen = false; + } + + get formValid(): boolean { + return this.model.title.trim().length > 0 && this.model.body.trim().length > 0; + } + + @HostListener('document:keydown.escape') + onEsc(): void { + if (this.drawerOpen) this.closeDrawer(); + } + + /** Publishing deactivates whatever is currently active — confirm before doing it. */ + confirmPublish(): void { + if (!this.formValid || this.saving) return; + const ref = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: 'Publish New Agreement', + dialogContext: + 'Publishing this version will immediately supersede the current active agreement — every ' + + 'new first-login user will be shown this version instead. Continue?', + type: 'warn' + } + }); + ref.afterClosed().subscribe((response: any) => { + if (!response?.confirm) return; + this.publish(); + }); + } + + private publish(): void { + this.saving = true; + this.service + .create({ + title: this.model.title.trim(), + body: this.model.body + }) + .subscribe({ + next: () => { + this.snackBar.open('Agreement published', 'Close', { duration: 3000 }); + this.saving = false; + this.drawerOpen = false; + this.load(); + }, + error: (err: any) => { + this.saving = false; + const message = + err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not publish.'; + this.snackBar.open(message, 'Close', { duration: 5000 }); + } + }); + } +} diff --git a/src/app/system/manage-user-agreement/user-agreement.service.ts b/src/app/system/manage-user-agreement/user-agreement.service.ts new file mode 100644 index 000000000..05643d0e3 --- /dev/null +++ b/src/app/system/manage-user-agreement/user-agreement.service.ts @@ -0,0 +1,41 @@ +/** + * 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 { Observable } from 'rxjs'; + +/** A single published version of the bank's user agreement/warning text. */ +export interface UserAgreement { + id: number; + version: number; + title: string; + body: string; + isActive?: boolean; + createdByUsername?: string; + createdOn?: string | number[]; +} + +@Injectable({ providedIn: 'root' }) +export class UserAgreementService { + private http = inject(HttpClient); + + /** List all published versions, newest first. */ + getAll(): Observable { + return this.http.get('/useragreements'); + } + + /** The currently-active version — the one new users must accept. */ + getActive(): Observable { + return this.http.get('/useragreements/active'); + } + + /** Publish a new version. The backend deactivates the previous active row automatically. */ + create(data: { title: string; body: string }): Observable<{ resourceId: number }> { + return this.http.post<{ resourceId: number }>('/useragreements', data); + } +} diff --git a/src/app/system/system-routing.module.ts b/src/app/system/system-routing.module.ts index b99efa2ee..1faa4843f 100644 --- a/src/app/system/system-routing.module.ts +++ b/src/app/system/system-routing.module.ts @@ -57,6 +57,7 @@ import { SystemComponent } from './system.component'; import { SystemInformationComponent } from './system-information/system-information.component'; import { AboutUsComponent } from './about-us/about-us.component'; import { DenominationMasterComponent } from './denomination-master/denomination-master.component'; +import { ManageUserAgreementComponent } from './manage-user-agreement/manage-user-agreement.component'; /** Custom Resolvers */ import { AccountNumberPreferencesResolver } from './account-number-preferences/account-number-preferences.resolver'; @@ -625,6 +626,16 @@ const routes: Routes = [ component: DenominationMasterComponent } ] + }, + { + path: 'user-agreement', + data: { title: 'Manage User Agreement', breadcrumb: 'Manage User Agreement' }, + children: [ + { + path: '', + component: ManageUserAgreementComponent + } + ] } ] } diff --git a/src/app/system/system.component.html b/src/app/system/system.component.html index 0d7d59675..c33e3668b 100644 --- a/src/app/system/system.component.html +++ b/src/app/system/system.component.html @@ -218,6 +218,20 @@ + + +
+ +
+
+

{{ 'labels.heading.Manage User Agreement' | translate }}

+

+ {{ 'labels.text.Publish versioned agreement text new users must accept on first login' | translate }} +

+
+ +
+
diff --git a/src/app/users/create-user/create-user.component.html b/src/app/users/create-user/create-user.component.html index 519399995..baf2100ba 100644 --- a/src/app/users/create-user/create-user.component.html +++ b/src/app/users/create-user/create-user.component.html @@ -124,6 +124,19 @@ Enter a valid 10-digit mobile number } + +
+
+ + + Email + Mobile + + @if (userForm.controls.otpDeliveryMethod.hasError('required')) { + Choose how this user will receive their first-login OTP. + } +
+
diff --git a/src/app/users/create-user/create-user.component.scss b/src/app/users/create-user/create-user.component.scss index d76540d5d..31dcb4cac 100644 --- a/src/app/users/create-user/create-user.component.scss +++ b/src/app/users/create-user/create-user.component.scss @@ -166,6 +166,14 @@ $gray-900: #202124; } } +.cu-radio-label { + display: block; + font-size: 12px; + font-weight: 500; + color: $gray-700; + margin-bottom: 6px; +} + .cu-error { grid-column: 1 / -1; color: #dc2626; diff --git a/src/app/users/create-user/create-user.component.ts b/src/app/users/create-user/create-user.component.ts index 605bf08c5..f51cef536 100644 --- a/src/app/users/create-user/create-user.component.ts +++ b/src/app/users/create-user/create-user.component.ts @@ -29,6 +29,7 @@ import { ConfigurationWizardService } from 'app/configuration-wizard/configurati import { ContinueSetupDialogComponent } from 'app/configuration-wizard/continue-setup-dialog/continue-setup-dialog.component'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatIcon } from '@angular/material/icon'; +import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -41,7 +42,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, MatCheckbox, - MatIcon + MatIcon, + MatRadioGroup, + MatRadioButton ] }) export class CreateUserComponent implements OnInit, AfterViewInit { @@ -142,6 +145,10 @@ export class CreateUserComponent implements OnInit, AfterViewInit { roles: [ '', Validators.required + ], + otpDeliveryMethod: [ + '', + Validators.required ] }, { validator: confirmPasswordValidator } diff --git a/src/app/users/user-form-dialog/user-form-dialog.component.html b/src/app/users/user-form-dialog/user-form-dialog.component.html index bed76d1e8..a9e26ac66 100644 --- a/src/app/users/user-form-dialog/user-form-dialog.component.html +++ b/src/app/users/user-form-dialog/user-form-dialog.component.html @@ -72,6 +72,27 @@ } + + @if (!isEdit) { +
+ +
+ + +
+ @if (form.get('otpDeliveryMethod')?.touched && form.get('otpDeliveryMethod')?.errors?.['required']) { +
Choose how this user will receive their OTP.
+ } +
+ } diff --git a/src/app/users/user-form-dialog/user-form-dialog.component.scss b/src/app/users/user-form-dialog/user-form-dialog.component.scss index e3bc69a84..a3bd7e71a 100644 --- a/src/app/users/user-form-dialog/user-form-dialog.component.scss +++ b/src/app/users/user-form-dialog/user-form-dialog.component.scss @@ -126,6 +126,12 @@ $purple: #7c3aed; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; + + // A field placed right after the grid (e.g. OTP Delivery Method, which spans full width + // and isn't part of the 2-column layout) needs its own breathing room above it. + + .ufd-field { + margin-top: 12px; + } } .ufd-field { @@ -268,6 +274,57 @@ $purple: #7c3aed; } } +.ufd-otp-methods { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ufd-otp-method { + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 14px; + border: 1px solid #cbd5e1; + background: #fff; + border-radius: 999px; + color: #475569; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.12s; + + fa-icon { + font-size: 12px; + } + + // stylelint-disable-next-line selector-no-qualifying-type + input[type='radio'] { + // Visually hidden but still focusable/clickable for accessibility - the pill itself + // carries the selected state via .is-selected, matching .ufd-role's pattern. + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + &:hover { + border-color: #94a3b8; + color: $navy; + } + + &.is-selected { + background: rgb(30 136 229 / 12%); + border-color: $blue; + color: $blue-d; + + fa-icon { + color: $blue; + } + } +} + .ufd-hint { display: flex; align-items: center; diff --git a/src/app/users/user-form-dialog/user-form-dialog.component.ts b/src/app/users/user-form-dialog/user-form-dialog.component.ts index da1bd190f..df8e05823 100644 --- a/src/app/users/user-form-dialog/user-form-dialog.component.ts +++ b/src/app/users/user-form-dialog/user-form-dialog.component.ts @@ -160,7 +160,11 @@ export class UserFormDialogComponent implements OnInit { Validators.required ], passwordNeverExpires: [false], - sendPasswordToEmail: [true] + sendPasswordToEmail: [true], + otpDeliveryMethod: [ + '', + Validators.required + ] }); } @@ -174,6 +178,9 @@ export class UserFormDialogComponent implements OnInit { const selectedRoleIds = (u?.selectedRoles || []).map((r: any) => r.id); // Edit mode never asks about password — strip those controls. this.form.removeControl('sendPasswordToEmail'); + // OTP delivery method is a create-only concept (first-login gate) — changing an + // existing user's channel post-creation is out of scope for this phase. + this.form.removeControl('otpDeliveryMethod'); this.form.patchValue({ username: u.username, firstname: u.firstname,