feat: MFA/2FA, OTP audit logs, KYC + client/user enhancements

- Access Control module: SMS/Email/TOTP/Hardware-token MFA factor management
- OTP Audit Logs module under Admin menu (list + detail) with 401/403 handling
- Two-factor authentication login flow updates
- Client KYC, India address, family-members, ckyc, nominee enhancements
- New shared KYC helpers (kyc-proof-types, kyc-upload-helper.service)
- User create/edit/view enhancements
- Shell navigation: OTP Audit Logs entry under real Admin menu
This commit is contained in:
Aditya
2026-05-10 13:24:48 +05:30
parent 2d4fb4237e
commit 3b752dc6ae
56 changed files with 7741 additions and 599 deletions
@@ -640,33 +640,163 @@
}
</h4>
<!-- Chip-grouped document type picker -->
<div class="kyc-doc-pickbox">
<div class="kyc-doc-pickbox__head">
<mat-icon class="kyc-doc-pickbox__icon">badge</mat-icon>
<div class="kyc-doc-pickbox__titles">
<p class="kyc-doc-pickbox__title">Identity proof</p>
<p class="kyc-doc-pickbox__sub">Select document type — upload requirements will update automatically</p>
</div>
</div>
<div class="kyc-doc-field">
<label class="kyc-doc-label"><mat-icon>description</mat-icon> Document type</label>
<div class="kyc-doc-chip-wrap">
@for (group of kycDocGroups; track group.label; let last = $last) {
<div class="kyc-doc-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="kyc-doc-chip-grid">
@for (p of group.items; track p.id) {
<span
class="kyc-doc-chip"
[class.kyc-doc-chip--sel]="isKycDocSelected(p)"
(click)="selectKycDoc(p)"
(keydown.enter)="selectKycDoc(p)"
tabindex="0"
role="button"
>
<mat-icon class="kyc-doc-chip__icon">
{{ isKycDocSelected(p) ? 'check_circle' : 'radio_button_unchecked' }}
</mat-icon>
{{ p.name }}
</span>
}
</div>
@if (!last) {
<div class="kyc-doc-chip-divider"></div>
}
}
</div>
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
<div class="kyc-doc-error">KYC Document Type is <strong>required</strong></div>
}
</div>
@if (selectedKycDoc?.info) {
<div class="kyc-doc-banner">
<mat-icon>check_circle</mat-icon>
<span>{{ selectedKycDoc.info }}</span>
</div>
}
@if (selectedKycDoc) {
<div class="kyc-doc-upload-grid" [class.kyc-doc-upload-grid--two]="selectedKycDoc.sides === 2">
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title">
<mat-icon>image</mat-icon>
{{ selectedKycDoc.sides === 2 ? 'Front side' : selectedKycDoc.name + ' (single copy)' }}
</span>
<span class="kyc-doc-upload-req">Required</span>
</div>
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.front"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
@if (kycDocFiles.front) {
<div class="kyc-doc-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="kyc-doc-dropzone__remove"
(click)="$event.preventDefault(); removeKycDocFile('front')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="kyc-doc-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
@if (selectedKycDoc.sides === 2) {
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<span class="kyc-doc-upload-req">Required</span>
</div>
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.back"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
@if (kycDocFiles.back) {
<div class="kyc-doc-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.back.name }}</span>
<button
type="button"
mat-icon-button
class="kyc-doc-dropzone__remove"
(click)="$event.preventDefault(); removeKycDocFile('back')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="kyc-doc-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
}
</div>
@if (selectedKycDoc.sides === 2 && kycDocMergedPreview) {
<div class="kyc-merged">
<div class="kyc-merged__head">
<span class="kyc-merged__title">
<mat-icon>filter_none</mat-icon>
Combined preview (front + back)
</span>
<span class="kyc-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
</div>
<div class="kyc-merged__preview">
<img [src]="kycDocMergedPreview" alt="Merged KYC document" />
</div>
<div class="kyc-merged__actions">
<button type="button" mat-stroked-button class="kyc-merged__btn" (click)="reCropKycDocFile('front')">
<mat-icon>crop</mat-icon> Re-crop front
</button>
<button type="button" mat-stroked-button class="kyc-merged__btn" (click)="reCropKycDocFile('back')">
<mat-icon>crop</mat-icon> Re-crop back
</button>
</div>
</div>
}
}
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>KYC Document Type</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>description</mat-icon></span>
<mat-select required formControlName="kycDocumentName">
@for (doc of kycDocumentTypes; track doc) {
<mat-option [value]="doc.name">
{{ doc.name }}
@if (doc.mandatory) {
<span class="doc-mandatory-star"> *</span>
}
</mat-option>
}
</mat-select>
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
<mat-error>KYC Document Type is <strong>required</strong></mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Document Number</mat-label>
<mat-label>{{ selectedKycDoc?.documentKeyLabel || 'Document Number' }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span>
<input
matInput
required
formControlName="kycDocumentNumber"
placeholder="As printed on document"
[placeholder]="selectedKycDoc?.documentKeyPlaceholder || 'As printed on document'"
maxlength="30"
style="text-transform: uppercase"
(input)="
@@ -1005,9 +1135,14 @@
<h4 class="section-title">
<fa-icon icon="camera" class="section-icon"></fa-icon>
Photograph &amp; Signature
<span class="section-required-tag">Required</span>
</h4>
<div class="photo-sig-grid">
<div class="upload-box" [class.upload-box--has-preview]="photographPreview">
<div
class="upload-box"
[class.upload-box--has-preview]="photographPreview"
[class.upload-box--missing]="!photographPreview && photoSigTouched"
>
@if (photographPreview) {
<div class="upload-preview">
<img [src]="photographPreview" alt="Photo" class="upload-preview__img upload-preview__img--photo" />
@@ -1023,15 +1158,25 @@
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
<span class="upload-box__label">Client Photograph</span>
<span class="upload-box__label">
Client Photograph
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
<fa-icon icon="camera"></fa-icon> Choose &amp; Crop
</label>
@if (photoSigTouched) {
<span class="upload-box__error">Photograph is required</span>
}
}
</div>
<div class="upload-box" [class.upload-box--has-preview]="signaturePreview">
<div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
@@ -1047,12 +1192,18 @@
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">Specimen Signature</span>
<span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
}
}
</div>
</div>