feat(users): OTP delivery method picker on user creation + post-login agreement
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 16:20:46 +05:30
co-authored by Claude Sonnet 5
parent d0536dbb11
commit d334dd1669
18 changed files with 1356 additions and 16 deletions
@@ -124,6 +124,19 @@
<mat-error>Enter a valid <strong>10-digit mobile number</strong></mat-error>
}
</mat-form-field>
<div class="cu-checkbox-row cu-field--full">
<div>
<label class="cu-radio-label">OTP Delivery Method *</label>
<mat-radio-group formControlName="otpDeliveryMethod">
<mat-radio-button value="EMAIL">Email</mat-radio-button>
<mat-radio-button value="SMS">Mobile</mat-radio-button>
</mat-radio-group>
@if (userForm.controls.otpDeliveryMethod.hasError('required')) {
<mat-error>Choose how this user will receive their first-login OTP.</mat-error>
}
</div>
</div>
</div>
</div>
@@ -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;
@@ -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 }
@@ -72,6 +72,27 @@
}
</div>
</div>
@if (!isEdit) {
<div class="ufd-field">
<label>OTP Delivery Method *</label>
<div class="ufd-otp-methods" role="radiogroup" aria-label="OTP Delivery Method">
<label class="ufd-otp-method" [class.is-selected]="form.value.otpDeliveryMethod === 'EMAIL'">
<input type="radio" formControlName="otpDeliveryMethod" value="EMAIL" />
<fa-icon icon="envelope"></fa-icon>
<span>Email</span>
</label>
<label class="ufd-otp-method" [class.is-selected]="form.value.otpDeliveryMethod === 'SMS'">
<input type="radio" formControlName="otpDeliveryMethod" value="SMS" />
<fa-icon icon="mobile-alt"></fa-icon>
<span>Mobile</span>
</label>
</div>
@if (form.get('otpDeliveryMethod')?.touched && form.get('otpDeliveryMethod')?.errors?.['required']) {
<div class="ufd-err">Choose how this user will receive their OTP.</div>
}
</div>
}
</div>
<!-- Office mapping -->
@@ -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;
@@ -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,