feat(mfa): Google Authenticator (TOTP) + branded 2FA login popup
- Settings → MFA Factors: wire the Authenticator tile to the real backend.
POST /v1/mfa/totp/setup mints the QR + secret + otpauth URL; the response
binds directly to <img [src]="totp.qrDataUri"> alongside a manual-entry
fallback ("Can't scan? Enter this in your app:") and an issuer/account
label. Verify hits POST /v1/mfa/totp/verify; on
error.msg.user.mfa.totp.code.invalid the message renders inline. Disable
uses DELETE /mfa/factors/{id}.
- Already-enrolled guard surfaces a dialog and re-hydrates from
/mfa/factors when error.msg.user.mfa.totp.already.enrolled is returned.
- Mobile click-to-add deep-link bound to otpAuthUrl (shown on touch-only
browsers via @media (hover:none)).
- access-control.service.ts: TotpSetupResponse + TotpVerifyResponse +
setupTotp() / verifyTotp() endpoints.
- hydrateFromServer / resetAllFactors now treat TOTP as a persisted factor
(verified, factorId, verifiedOn) instead of local state.
- Login 2FA picker: iconFor() returns 'qrcode' for TOTP, OTP-context copy
switches to "Open your authenticator app", Resend button hidden,
validity badge reads "rotates every 30 s".
- 2FA popup redesigned: CBS Nexora brand lockup at the top (logomark +
wordmark + 'Secure Sign-in' sub-line), refined card depth with stacked
shadow + ambient glow, segmented 6-cell OTP input, brighter primary
button gradient, responsive collapse at <=480px.
- Register faQrcode in icons.module.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
595054b0b8
commit
4322f37c6d
@@ -50,7 +50,9 @@
|
|||||||
"Bash(findstr /n \"faExchange faBolt faClock faPaperPlane faFlask faSpinner faUserFriends faCommentDots faListAlt faInfoCircle faSearch faTimes faUndo faCheckCircle faExclamationTriangle\" \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\shared\\\\icons.module.ts\")",
|
"Bash(findstr /n \"faExchange faBolt faClock faPaperPlane faFlask faSpinner faUserFriends faCommentDots faListAlt faInfoCircle faSearch faTimes faUndo faCheckCircle faExclamationTriangle\" \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\shared\\\\icons.module.ts\")",
|
||||||
"Bash(findstr /i \"error neft-rtgs digital\")",
|
"Bash(findstr /i \"error neft-rtgs digital\")",
|
||||||
"Bash(findstr /I /C:\"error\" /C:\"neft-rtgs\" /C:\"digital\")",
|
"Bash(findstr /I /C:\"error\" /C:\"neft-rtgs\" /C:\"digital\")",
|
||||||
"Bash(findstr /V \"warning\")"
|
"Bash(findstr /V \"warning\")",
|
||||||
|
"Bash(dir \"C:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\settings\")",
|
||||||
|
"Bash(findstr \"merge_code_update\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -622,32 +622,36 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="ac-panel__body">
|
<div class="ac-panel__body">
|
||||||
@if (!totp.secret && !totp.verified) {
|
@if (!totp.qrDataUri && !totp.verified) {
|
||||||
<div class="ac-totp-empty">
|
<div class="ac-totp-empty">
|
||||||
<mat-icon>qr_code_scanner</mat-icon>
|
<mat-icon>qr_code_scanner</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<strong>No authenticator linked yet</strong>
|
<strong>No authenticator linked yet</strong>
|
||||||
<p>Generate a setup key, scan it in your TOTP app, then verify the 6-digit code shown there.</p>
|
<p>
|
||||||
|
Click <strong>Set up</strong> to generate a QR for Google Authenticator (or any TOTP app), then enter
|
||||||
|
the 6-digit code to activate.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button mat-flat-button color="primary" (click)="generateTotpSecret()">
|
<button mat-flat-button color="primary" [disabled]="totp.busy" (click)="beginTotpSetup()">
|
||||||
<mat-icon>auto_awesome</mat-icon> Generate setup key
|
<mat-icon>auto_awesome</mat-icon> {{ totp.busy ? 'Starting…' : 'Set up' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else if (totp.secret && !totp.verified) {
|
} @else if (totp.qrDataUri && !totp.verified) {
|
||||||
<div class="ac-totp-setup">
|
<div class="ac-totp-setup">
|
||||||
<div class="ac-totp-setup__qr">
|
<div class="ac-totp-setup__qr">
|
||||||
<div class="ac-qr-frame">
|
<div class="ac-qr-frame">
|
||||||
<div class="ac-qr-grid">
|
<img class="ac-qr-img" [src]="totp.qrDataUri" alt="Authenticator setup QR" />
|
||||||
@for (row of totpQrCells; track $index) {
|
|
||||||
<div class="ac-qr-row">
|
|
||||||
@for (cell of row; track $index) {
|
|
||||||
<span class="ac-qr-cell" [class.is-on]="cell"></span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="ac-qr-caption">Scan in your authenticator app</p>
|
<p class="ac-qr-caption">Scan in your authenticator app</p>
|
||||||
|
<p class="ac-qr-issuer">
|
||||||
|
Issuer: <strong>{{ totp.issuer }}</strong> · {{ totp.accountName }}
|
||||||
|
</p>
|
||||||
|
@if (totp.otpAuthUrl) {
|
||||||
|
<a class="ac-qr-deeplink" [href]="totp.otpAuthUrl">
|
||||||
|
<mat-icon>smartphone</mat-icon>
|
||||||
|
On a phone? Tap to add to your authenticator
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ac-totp-setup__details">
|
<div class="ac-totp-setup__details">
|
||||||
<div class="ac-totp-step-list">
|
<div class="ac-totp-step-list">
|
||||||
@@ -658,7 +662,7 @@
|
|||||||
<div class="ac-totp-step">
|
<div class="ac-totp-step">
|
||||||
<span class="ac-totp-step__num">2</span>
|
<span class="ac-totp-step__num">2</span>
|
||||||
<div>
|
<div>
|
||||||
Scan the code on the left, or enter the secret manually.
|
Scan the code on the left. <em>Can't scan?</em> Enter this in your app:
|
||||||
<div class="ac-totp-secret">
|
<div class="ac-totp-secret">
|
||||||
<code>{{ totp.secret }}</code>
|
<code>{{ totp.secret }}</code>
|
||||||
<button mat-icon-button matTooltip="Copy" (click)="copyTotpSecret()">
|
<button mat-icon-button matTooltip="Copy" (click)="copyTotpSecret()">
|
||||||
@@ -669,7 +673,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ac-totp-step">
|
<div class="ac-totp-step">
|
||||||
<span class="ac-totp-step__num">3</span>
|
<span class="ac-totp-step__num">3</span>
|
||||||
<div>Enter the 6-digit code shown by your app to verify and register this device.</div>
|
<div>Enter the 6-digit code shown by your app to verify and activate.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -690,9 +694,9 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<div class="ac-totp-actions">
|
<div class="ac-totp-actions">
|
||||||
<button mat-flat-button color="primary" [disabled]="totp.busy" (click)="verifyTotp()">
|
<button mat-flat-button color="primary" [disabled]="totp.busy" (click)="verifyTotp()">
|
||||||
<mat-icon>verified</mat-icon> {{ totp.busy ? 'Verifying…' : 'Verify & Register' }}
|
<mat-icon>verified</mat-icon> {{ totp.busy ? 'Verifying…' : 'Verify & activate' }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-stroked-button color="warn" (click)="disableTotp()">
|
<button mat-stroked-button color="warn" [disabled]="totp.busy" (click)="cancelTotpSetup()">
|
||||||
<mat-icon>refresh</mat-icon> Discard & restart
|
<mat-icon>refresh</mat-icon> Discard & restart
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -702,11 +706,11 @@
|
|||||||
<div class="ac-otp-done">
|
<div class="ac-otp-done">
|
||||||
<mat-icon>check_circle</mat-icon>
|
<mat-icon>check_circle</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<strong>Authenticator app registered</strong>
|
<strong>Google Authenticator linked</strong>
|
||||||
<span>Sign-in will require a 6-digit TOTP code from your app.</span>
|
<span>Sign-in will require a 6-digit code from your authenticator app.</span>
|
||||||
</div>
|
</div>
|
||||||
<button mat-stroked-button color="warn" (click)="disableTotp()">
|
<button mat-stroked-button color="warn" [disabled]="totp.busy" (click)="disableTotp()">
|
||||||
<mat-icon>power_settings_new</mat-icon> Unlink
|
<mat-icon>power_settings_new</mat-icon> Disable
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1131,6 +1131,58 @@
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ac-qr-img {
|
||||||
|
width: 168px;
|
||||||
|
height: 168px;
|
||||||
|
display: block;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ac-qr-issuer {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ac-qr-deeplink {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show the click-to-add link on touch / handheld widths */
|
||||||
|
@media (width <= 760px), (hover: none) {
|
||||||
|
.ac-qr-deeplink {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ac-totp-step-list {
|
.ac-totp-step-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -63,9 +63,24 @@ interface OtpChannelState {
|
|||||||
|
|
||||||
interface TotpState {
|
interface TotpState {
|
||||||
typeCode: 'TOTP';
|
typeCode: 'TOTP';
|
||||||
|
/** Base-32 shared secret returned by /mfa/totp/setup (manual-entry fallback). */
|
||||||
secret: string;
|
secret: string;
|
||||||
|
/** `data:image/png;base64,...` — bound directly to <img [src]>. Empty when no live setup. */
|
||||||
|
qrDataUri: string;
|
||||||
|
/** Full otpauth:// URL — used by the mobile click-to-add deep-link. */
|
||||||
|
otpAuthUrl: string;
|
||||||
|
/** Issuer label shown by the authenticator app. */
|
||||||
|
issuer: string;
|
||||||
|
/** Account label shown by the authenticator app (typically the username). */
|
||||||
|
accountName: string;
|
||||||
|
/** 6-digit code the user enters from their authenticator. */
|
||||||
otp: string;
|
otp: string;
|
||||||
|
/** True once the factor is persisted on the server. */
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
/** Persisted factor id for disable. */
|
||||||
|
factorId: number | null;
|
||||||
|
/** ISO timestamp from the server. */
|
||||||
|
verifiedOn: string;
|
||||||
errorMsg: string;
|
errorMsg: string;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}
|
}
|
||||||
@@ -142,14 +157,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
email: OtpChannelState = this.makeOtpState('EMAIL');
|
email: OtpChannelState = this.makeOtpState('EMAIL');
|
||||||
|
|
||||||
/** Section 2 — Authenticator app (TOTP) */
|
/** Section 2 — Authenticator app (TOTP) */
|
||||||
totp: TotpState = {
|
totp: TotpState = this.makeTotpState();
|
||||||
typeCode: 'TOTP',
|
|
||||||
secret: '',
|
|
||||||
otp: '',
|
|
||||||
verified: false,
|
|
||||||
errorMsg: '',
|
|
||||||
busy: false
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Section 3 — Hardware Token (Smart Card / DSC / Biometric) */
|
/** Section 3 — Hardware Token (Smart Card / DSC / Biometric) */
|
||||||
hardware: HardwareItem[] = [
|
hardware: HardwareItem[] = [
|
||||||
@@ -310,9 +318,19 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
this.applyFactor(this.sms, f);
|
this.applyFactor(this.sms, f);
|
||||||
} else if (f.factorType === 'EMAIL') {
|
} else if (f.factorType === 'EMAIL') {
|
||||||
this.applyFactor(this.email, f);
|
this.applyFactor(this.email, f);
|
||||||
} else if (f.factorType === 'TOTP' && f.verified) {
|
} else if (f.factorType === 'TOTP') {
|
||||||
this.totp.secret = f.secret || this.totp.secret;
|
// Verified+active TOTP means the user already has Google Authenticator
|
||||||
this.totp.verified = true;
|
// enrolled. Don't expose the secret again — only after a fresh setup.
|
||||||
|
const isActive = !f.disabledOn && !!f.verified;
|
||||||
|
this.totp.verified = !!f.verified;
|
||||||
|
this.totp.factorId = isActive ? (f.id ?? null) : null;
|
||||||
|
this.totp.verifiedOn = f.verifiedOn || '';
|
||||||
|
// Clear any pending setup state — server is authoritative.
|
||||||
|
this.totp.secret = '';
|
||||||
|
this.totp.qrDataUri = '';
|
||||||
|
this.totp.otpAuthUrl = '';
|
||||||
|
this.totp.otp = '';
|
||||||
|
this.totp.errorMsg = '';
|
||||||
} else if (f.factorType === 'DSC') {
|
} else if (f.factorType === 'DSC') {
|
||||||
// Match by serial, falling back to the DSC tile if no serial match
|
// Match by serial, falling back to the DSC tile if no serial match
|
||||||
// (DSC is the only hardware kind backed by the factor API today).
|
// (DSC is the only hardware kind backed by the factor API today).
|
||||||
@@ -359,6 +377,12 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
this.email.verifiedOn = '';
|
this.email.verifiedOn = '';
|
||||||
this.totp.verified = false;
|
this.totp.verified = false;
|
||||||
this.totp.secret = '';
|
this.totp.secret = '';
|
||||||
|
this.totp.qrDataUri = '';
|
||||||
|
this.totp.otpAuthUrl = '';
|
||||||
|
this.totp.otp = '';
|
||||||
|
this.totp.errorMsg = '';
|
||||||
|
this.totp.factorId = null;
|
||||||
|
this.totp.verifiedOn = '';
|
||||||
for (const h of this.hardware) {
|
for (const h of this.hardware) {
|
||||||
h.registered = false;
|
h.registered = false;
|
||||||
h.serial = '';
|
h.serial = '';
|
||||||
@@ -608,47 +632,45 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// ───────────────────────────── Authenticator app (TOTP)
|
// ───────────────────────────── Authenticator app (TOTP)
|
||||||
|
|
||||||
generateTotpSecret(): void {
|
/**
|
||||||
this.totp.secret = this.randomBase32(16);
|
* Begin TOTP enrolment: ask the backend to mint a secret + QR. Populates
|
||||||
this.totp.otp = '';
|
* `qrDataUri`, `secret`, `otpAuthUrl`, `issuer` from the response so the
|
||||||
this.totp.verified = false;
|
* UI can render the QR and the manual-entry fallback in one step.
|
||||||
|
*
|
||||||
|
* If the user already has TOTP enrolled, the backend returns
|
||||||
|
* `error.msg.user.mfa.totp.already.enrolled` — we surface it as a toast.
|
||||||
|
*/
|
||||||
|
beginTotpSetup(): void {
|
||||||
this.totp.errorMsg = '';
|
this.totp.errorMsg = '';
|
||||||
}
|
this.totp.busy = true;
|
||||||
|
this.accessControlService.setupTotp().subscribe({
|
||||||
/** otpauth URI shown for manual entry / QR encoding by the user's authenticator app */
|
next: (res) => {
|
||||||
get totpOtpauthUri(): string {
|
this.totp.busy = false;
|
||||||
if (!this.totp.secret) return '';
|
this.totp.qrDataUri = res?.qrDataUri || '';
|
||||||
const issuer = 'CBSNexora';
|
this.totp.secret = res?.secret || '';
|
||||||
const label = `${issuer}:${this.username}`;
|
this.totp.otpAuthUrl = res?.otpAuthUrl || '';
|
||||||
return `otpauth://totp/${encodeURIComponent(label)}?secret=${this.totp.secret}&issuer=${encodeURIComponent(issuer)}`;
|
this.totp.issuer = res?.issuer || 'CBSNexora';
|
||||||
}
|
this.totp.accountName = res?.accountName || this.username;
|
||||||
|
this.totp.otp = '';
|
||||||
/** Visual placeholder QR — deterministic 8x8 grid from the secret string */
|
this.totp.verified = false;
|
||||||
get totpQrCells(): boolean[][] {
|
this.totp.factorId = null;
|
||||||
if (!this.totp.secret) return [];
|
this.totp.verifiedOn = '';
|
||||||
const grid: boolean[][] = [];
|
},
|
||||||
let seed = 0;
|
error: (err) => {
|
||||||
for (let i = 0; i < this.totp.secret.length; i++) {
|
this.totp.busy = false;
|
||||||
seed = (seed * 31 + this.totp.secret.charCodeAt(i)) & 0xffffffff;
|
const apiMsg =
|
||||||
}
|
err?.error?.userMessageGlobalisationCode || err?.error?.defaultUserMessage || err?.error?.message;
|
||||||
for (let r = 0; r < 8; r++) {
|
if (apiMsg === 'error.msg.user.mfa.totp.already.enrolled' || /already.enrolled/i.test(apiMsg || '')) {
|
||||||
const row: boolean[] = [];
|
this.showError(
|
||||||
for (let c = 0; c < 8; c++) {
|
'Google Authenticator is already enrolled. Disable the existing factor before setting it up again.'
|
||||||
// Three corner finder squares
|
);
|
||||||
const corner =
|
// Re-hydrate so the UI matches the server's reality.
|
||||||
(r < 3 && c < 3) || (r < 3 && c > 4) || (r > 4 && c < 3)
|
this.hydrateFromServer();
|
||||||
? r === 0 || r === 2 || r === 5 || r === 7 || c === 0 || c === 2 || c === 5 || c === 7
|
|
||||||
: null;
|
|
||||||
if (corner !== null) {
|
|
||||||
row.push(corner);
|
|
||||||
} else {
|
} else {
|
||||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
this.showError(apiMsg || 'Could not start TOTP setup. Try again.');
|
||||||
row.push((seed & 1) === 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
grid.push(row);
|
});
|
||||||
}
|
|
||||||
return grid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
copyTotpSecret(): void {
|
copyTotpSecret(): void {
|
||||||
@@ -660,6 +682,11 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the 6-digit code from the authenticator app to /mfa/totp/verify.
|
||||||
|
* On success the backend persists the factor row; on
|
||||||
|
* `error.msg.user.mfa.totp.code.invalid` we show the message inline.
|
||||||
|
*/
|
||||||
verifyTotp(): void {
|
verifyTotp(): void {
|
||||||
const code = (this.totp.otp || '').trim();
|
const code = (this.totp.otp || '').trim();
|
||||||
if (!/^\d{6}$/.test(code)) {
|
if (!/^\d{6}$/.test(code)) {
|
||||||
@@ -668,25 +695,72 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.totp.errorMsg = '';
|
this.totp.errorMsg = '';
|
||||||
this.totp.busy = true;
|
this.totp.busy = true;
|
||||||
setTimeout(() => {
|
this.accessControlService.verifyTotp(code).subscribe({
|
||||||
this.totp.busy = false;
|
next: (res) => {
|
||||||
this.totp.verified = true;
|
this.totp.busy = false;
|
||||||
this.savePrefs();
|
if (!res?.verified) {
|
||||||
}, 500);
|
this.totp.errorMsg = res?.message || 'Invalid code — try a fresh one from your authenticator.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.totp.verified = true;
|
||||||
|
this.totp.factorId = res.factorId ?? null;
|
||||||
|
this.totp.verifiedOn = res.verifiedOn || new Date().toISOString();
|
||||||
|
// Clear setup-only artefacts now that the factor is live.
|
||||||
|
this.totp.qrDataUri = '';
|
||||||
|
this.totp.secret = '';
|
||||||
|
this.totp.otpAuthUrl = '';
|
||||||
|
this.totp.otp = '';
|
||||||
|
this.savePrefs();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.totp.busy = false;
|
||||||
|
const code = err?.error?.userMessageGlobalisationCode || err?.error?.errors?.[0]?.userMessageGlobalisationCode;
|
||||||
|
if (code === 'error.msg.user.mfa.totp.code.invalid' || /code\.invalid/i.test(code || '')) {
|
||||||
|
this.totp.errorMsg = 'That code is incorrect or expired. Try the next one (codes rotate every 30 s).';
|
||||||
|
} else {
|
||||||
|
this.totp.errorMsg = err?.error?.defaultUserMessage || err?.error?.message || 'Verification failed.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Discard a pending setup (before verify) — clears the QR/secret but does NOT call the API. */
|
||||||
|
cancelTotpSetup(): void {
|
||||||
|
this.totp.qrDataUri = '';
|
||||||
|
this.totp.secret = '';
|
||||||
|
this.totp.otpAuthUrl = '';
|
||||||
|
this.totp.otp = '';
|
||||||
|
this.totp.errorMsg = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disable an enrolled TOTP factor — DELETE /mfa/factors/{id}. */
|
||||||
disableTotp(): void {
|
disableTotp(): void {
|
||||||
this.totp = { typeCode: 'TOTP', secret: '', otp: '', verified: false, errorMsg: '', busy: false };
|
if (!this.totp.verified) {
|
||||||
this.savePrefs();
|
this.cancelTotpSetup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const finish = () => {
|
||||||
|
this.totp = this.makeTotpState();
|
||||||
|
this.savePrefs();
|
||||||
|
this.showSuccess(
|
||||||
|
'Authenticator app unlinked',
|
||||||
|
'The next sign-in will no longer require a 6-digit code from your authenticator.'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (this.mfaFactorsApiAvailable && this.totp.factorId != null) {
|
||||||
|
this.totp.busy = true;
|
||||||
|
this.accessControlService.disableFactor(this.totp.factorId).subscribe({
|
||||||
|
next: () => finish(),
|
||||||
|
error: () => finish() // local state already reflects intent; keep UX consistent
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private randomBase32(len: number): string {
|
/** Mobile deep-link: tapping this on a phone hands the secret off to the authenticator. */
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
get totpDeepLink(): string {
|
||||||
let out = '';
|
return this.totp.otpAuthUrl || '';
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
out += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────── Hardware Token
|
// ───────────────────────────── Hardware Token
|
||||||
@@ -1010,6 +1084,23 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// ───────────────────────────── Persistence (localStorage stub)
|
// ───────────────────────────── Persistence (localStorage stub)
|
||||||
|
|
||||||
|
private makeTotpState(): TotpState {
|
||||||
|
return {
|
||||||
|
typeCode: 'TOTP',
|
||||||
|
secret: '',
|
||||||
|
qrDataUri: '',
|
||||||
|
otpAuthUrl: '',
|
||||||
|
issuer: 'CBSNexora',
|
||||||
|
accountName: this.username || '',
|
||||||
|
otp: '',
|
||||||
|
verified: false,
|
||||||
|
factorId: null,
|
||||||
|
verifiedOn: '',
|
||||||
|
errorMsg: '',
|
||||||
|
busy: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private makeOtpState(typeCode: 'SMS' | 'EMAIL'): OtpChannelState {
|
private makeOtpState(typeCode: 'SMS' | 'EMAIL'): OtpChannelState {
|
||||||
return {
|
return {
|
||||||
typeCode,
|
typeCode,
|
||||||
|
|||||||
@@ -24,6 +24,28 @@ export interface OtpVerifyResponse {
|
|||||||
|
|
||||||
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN' | 'DSC';
|
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN' | 'DSC';
|
||||||
|
|
||||||
|
/** Payload returned from POST /v1/mfa/totp/setup — ready to bind directly to the UI. */
|
||||||
|
export interface TotpSetupResponse {
|
||||||
|
/** `data:image/png;base64,...` — drops straight into <img [src]>. */
|
||||||
|
qrDataUri: string;
|
||||||
|
/** Base-32 shared secret (the manual-entry fallback when QR can't be scanned). */
|
||||||
|
secret: string;
|
||||||
|
/** Full `otpauth://` URL — useful for the mobile "click to add" deep-link. */
|
||||||
|
otpAuthUrl: string;
|
||||||
|
/** Issuer label the authenticator app shows (e.g. "CBSNexora"). */
|
||||||
|
issuer: string;
|
||||||
|
/** Optional account label the authenticator app shows (e.g. the username). */
|
||||||
|
accountName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from POST /v1/mfa/totp/verify. Either echoes the saved factor or a verified flag. */
|
||||||
|
export interface TotpVerifyResponse {
|
||||||
|
verified: boolean;
|
||||||
|
factorId?: number;
|
||||||
|
verifiedOn?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MfaFactor {
|
export interface MfaFactor {
|
||||||
id?: number;
|
id?: number;
|
||||||
factorType: MfaFactorType;
|
factorType: MfaFactorType;
|
||||||
@@ -96,4 +118,21 @@ export class AccessControlService {
|
|||||||
headers: this.silentHeaders
|
headers: this.silentHeaders
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───── TOTP (Google Authenticator and friends) ─────
|
||||||
|
/**
|
||||||
|
* Begin TOTP enrolment: backend mints a fresh secret, returns the QR (as a
|
||||||
|
* ready-to-use data URI) plus the secret and otpauth URL for fallback flows.
|
||||||
|
*/
|
||||||
|
setupTotp(): Observable<TotpSetupResponse> {
|
||||||
|
return this.http.post<TotpSetupResponse>('/v1/mfa/totp/setup', {}, { headers: this.silentHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm enrolment by submitting the 6-digit code from the authenticator
|
||||||
|
* app. Backend persists the verified factor + returns its id.
|
||||||
|
*/
|
||||||
|
verifyTotp(code: string): Observable<TotpVerifyResponse> {
|
||||||
|
return this.http.post<TotpVerifyResponse>('/v1/mfa/totp/verify', { code }, { headers: this.silentHeaders });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="tfa-card">
|
<div class="tfa-card">
|
||||||
|
<!-- CBS Nexora brand lockup -->
|
||||||
|
<div class="tfa-brand">
|
||||||
|
<div class="tfa-logomark" aria-hidden="true"><span></span><span></span><span></span><span></span></div>
|
||||||
|
<div class="tfa-brand-text">
|
||||||
|
CBS<em>Nexora</em>
|
||||||
|
<span class="tfa-brand-sub">Secure Sign-in</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tfa-icon">
|
<div class="tfa-icon">
|
||||||
<fa-icon icon="shield-alt"></fa-icon>
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,6 +26,8 @@
|
|||||||
Plug in your DSC token and follow the prompts in the helper.
|
Plug in your DSC token and follow the prompts in the helper.
|
||||||
} @else if (!otpRequested) {
|
} @else if (!otpRequested) {
|
||||||
Choose how you want to verify your identity.
|
Choose how you want to verify your identity.
|
||||||
|
} @else if (isSelectedTotp) {
|
||||||
|
Open your authenticator app and enter the 6-digit code below.
|
||||||
} @else {
|
} @else {
|
||||||
Enter the 6-digit code we sent you to continue.
|
Enter the 6-digit code we sent you to continue.
|
||||||
}
|
}
|
||||||
@@ -140,13 +151,25 @@
|
|||||||
<div class="tfa-context">
|
<div class="tfa-context">
|
||||||
<fa-icon [icon]="iconFor(selectedTwoFactorAuthenticationDeliveryMethod)"></fa-icon>
|
<fa-icon [icon]="iconFor(selectedTwoFactorAuthenticationDeliveryMethod)"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<span class="tfa-context__label">Code sent to</span>
|
@if (isSelectedTotp) {
|
||||||
<strong class="tfa-context__target">{{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}</strong>
|
<span class="tfa-context__label">Open your authenticator app</span>
|
||||||
|
<strong class="tfa-context__target">Enter the 6-digit code</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="tfa-context__label">Code sent to</span>
|
||||||
|
<strong class="tfa-context__target">{{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}</strong>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<span class="tfa-context__validity">
|
@if (!isSelectedTotp) {
|
||||||
<fa-icon icon="clock"></fa-icon>
|
<span class="tfa-context__validity">
|
||||||
{{ tokenValidityTime / 60 }} {{ 'labels.inputs.mins' | translate }}
|
<fa-icon icon="clock"></fa-icon>
|
||||||
</span>
|
{{ tokenValidityTime / 60 }} {{ 'labels.inputs.mins' | translate }}
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="tfa-context__validity">
|
||||||
|
<fa-icon icon="redo"></fa-icon>
|
||||||
|
rotates every 30 s
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="tfa-form" [formGroup]="twoFactorAuthenticationForm" (ngSubmit)="validateOTP()">
|
<form class="tfa-form" [formGroup]="twoFactorAuthenticationForm" (ngSubmit)="validateOTP()">
|
||||||
@@ -184,20 +207,22 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
@if (!isSelectedTotp) {
|
||||||
type="button"
|
<button
|
||||||
class="tfa-btn tfa-btn--ghost"
|
type="button"
|
||||||
(click)="resendOTP()"
|
class="tfa-btn tfa-btn--ghost"
|
||||||
[disabled]="loading || resendOTPLoading"
|
(click)="resendOTP()"
|
||||||
>
|
[disabled]="loading || resendOTPLoading"
|
||||||
@if (resendOTPLoading) {
|
>
|
||||||
<mat-spinner diameter="18"></mat-spinner>
|
@if (resendOTPLoading) {
|
||||||
<span>Resending…</span>
|
<mat-spinner diameter="18"></mat-spinner>
|
||||||
} @else {
|
<span>Resending…</span>
|
||||||
<fa-icon icon="redo"></fa-icon>
|
} @else {
|
||||||
<span>{{ 'labels.buttons.Resend OTP' | translate }}</span>
|
<fa-icon icon="redo"></fa-icon>
|
||||||
}
|
<span>{{ 'labels.buttons.Resend OTP' | translate }}</span>
|
||||||
</button>
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,38 +15,135 @@
|
|||||||
|
|
||||||
.tfa-card {
|
.tfa-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 420px;
|
||||||
padding: 24px 22px 22px;
|
padding: 20px 24px 24px;
|
||||||
border-radius: 18px;
|
border-radius: 20px;
|
||||||
background: rgb(255 255 255 / 6%);
|
background: linear-gradient(180deg, rgb(255 255 255 / 7%) 0%, rgb(255 255 255 / 4%) 100%);
|
||||||
border: 1px solid rgb(255 255 255 / 12%);
|
border: 1px solid rgb(255 255 255 / 14%);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(12px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle ambient glow behind the brand */
|
||||||
|
.tfa-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 280px;
|
||||||
|
height: 220px;
|
||||||
|
background: radial-gradient(ellipse at center, rgb(96 165 250 / 22%) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────── Brand lockup ──────── */
|
||||||
|
.tfa-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px dashed rgb(255 255 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfa-logomark {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
border-radius: 2.5px;
|
||||||
|
|
||||||
|
&:nth-child(1) {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
background: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(4) {
|
||||||
|
background: rgb(255 255 255 / 30%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfa-brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #5ec9f8;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfa-brand-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: rgb(255 255 255 / 55%);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────── Centred action icon ──────── */
|
||||||
.tfa-icon {
|
.tfa-icon {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
width: 56px;
|
width: 60px;
|
||||||
height: 56px;
|
height: 60px;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 24px;
|
font-size: 26px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 6px 20px rgb(37 99 235 / 35%),
|
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%);
|
inset 0 1px 0 rgb(255 255 255 / 25%);
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfa-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: radial-gradient(circle, rgb(96 165 250 / 35%) 0%, transparent 70%);
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tfa-title {
|
.tfa-title {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 18px;
|
font-size: 19px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
@@ -56,7 +153,7 @@
|
|||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgb(255 255 255 / 65%);
|
color: rgb(255 255 255 / 70%);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,41 +355,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ──────── Step 2 — OTP input ──────── */
|
/* ──────── Step 2 — OTP input (segmented box look) ──────── */
|
||||||
.tfa-otp {
|
.tfa-otp {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tfa-otp__input {
|
.tfa-otp__input {
|
||||||
width: 100%;
|
--cell-w: 46px;
|
||||||
max-width: 320px;
|
--cell-gap: 10px;
|
||||||
height: 60px;
|
|
||||||
text-align: center;
|
width: calc(var(--cell-w) * 6 + var(--cell-gap) * 5);
|
||||||
border: 1px solid rgb(255 255 255 / 18%);
|
max-width: 100%;
|
||||||
background: rgb(255 255 255 / 8%);
|
height: 56px;
|
||||||
border-radius: 14px;
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background:
|
||||||
|
/* Six rounded cells, drawn as a repeating gradient mask */ linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgb(255 255 255 / 8%) 0,
|
||||||
|
rgb(255 255 255 / 8%) var(--cell-w),
|
||||||
|
transparent var(--cell-w),
|
||||||
|
transparent calc(var(--cell-w) + var(--cell-gap))
|
||||||
|
)
|
||||||
|
repeat-x;
|
||||||
|
background-size: calc(var(--cell-w) + var(--cell-gap)) 100%;
|
||||||
|
border-radius: 12px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 14px;
|
|
||||||
padding: 0 0 0 14px;
|
/* (cell width - glyph width) per character */
|
||||||
|
letter-spacing: calc(var(--cell-w) - 17px);
|
||||||
|
padding-left: calc((var(--cell-w) / 2) - 8px);
|
||||||
|
caret-color: #60a5fa;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(255 255 255 / 14%),
|
||||||
|
0 4px 14px rgb(0 0 0 / 28%);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.18s,
|
|
||||||
box-shadow 0.18s,
|
box-shadow 0.18s,
|
||||||
background 0.18s;
|
background 0.18s;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: rgb(255 255 255 / 25%);
|
color: rgb(255 255 255 / 28%);
|
||||||
letter-spacing: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: #60a5fa;
|
box-shadow:
|
||||||
background: rgb(255 255 255 / 12%);
|
inset 0 0 0 1.5px #60a5fa,
|
||||||
box-shadow: 0 0 0 4px rgb(96 165 250 / 25%);
|
0 0 0 4px rgb(96 165 250 / 22%),
|
||||||
|
0 4px 14px rgb(0 0 0 / 28%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,17 +463,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 60%, #1e3a8a 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 6px 20px rgb(37 99 235 / 32%),
|
0 10px 24px rgb(37 99 235 / 38%),
|
||||||
inset 0 1px 0 rgb(255 255 255 / 18%);
|
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) {
|
&:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
background: linear-gradient(135deg, #4f8ff7 0%, #2563eb 60%, #1e3a8a 100%);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 24px rgb(37 99 235 / 42%),
|
0 14px 28px rgb(37 99 235 / 48%),
|
||||||
inset 0 1px 0 rgb(255 255 255 / 22%);
|
0 0 0 1px rgb(255 255 255 / 8%),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 26%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,13 +517,23 @@
|
|||||||
/* ──────── Responsive ──────── */
|
/* ──────── Responsive ──────── */
|
||||||
@media (width <= 480px) {
|
@media (width <= 480px) {
|
||||||
.tfa-card {
|
.tfa-card {
|
||||||
padding: 20px 16px;
|
padding: 18px 16px 20px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tfa-otp__input {
|
.tfa-otp__input {
|
||||||
|
--cell-w: 38px;
|
||||||
|
--cell-gap: 8px;
|
||||||
|
|
||||||
|
height: 50px;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: calc(var(--cell-w) - 14px);
|
||||||
|
padding-left: calc((var(--cell-w) / 2) - 7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfa-icon {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
letter-spacing: 10px;
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,10 +210,17 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
|
|||||||
iconFor(method: any): string {
|
iconFor(method: any): string {
|
||||||
const name = (method?.name || '').toLowerCase();
|
const name = (method?.name || '').toLowerCase();
|
||||||
if (method?.kind === 'DSC' || name === 'dsc') return 'signature';
|
if (method?.kind === 'DSC' || name === 'dsc') return 'signature';
|
||||||
|
if (name === 'totp' || name === 'authenticator' || name === 'google authenticator') return 'qrcode';
|
||||||
if (name === 'sms' || name === 'mobile' || name === 'phone') return 'mobile-alt';
|
if (name === 'sms' || name === 'mobile' || name === 'phone') return 'mobile-alt';
|
||||||
return 'envelope';
|
return 'envelope';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the picked second factor is the authenticator app (TOTP). */
|
||||||
|
get isSelectedTotp(): boolean {
|
||||||
|
const name = (this.selectedTwoFactorAuthenticationDeliveryMethod?.name || '').toString().toLowerCase();
|
||||||
|
return name === 'totp' || name === 'authenticator' || name === 'google authenticator';
|
||||||
|
}
|
||||||
|
|
||||||
/** Start a DSC login challenge: POST /challenge → fire JNLP → begin polling. */
|
/** Start a DSC login challenge: POST /challenge → fire JNLP → begin polling. */
|
||||||
requestDscChallenge(method: any): void {
|
requestDscChallenge(method: any): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ import {
|
|||||||
faBrain,
|
faBrain,
|
||||||
faRobot,
|
faRobot,
|
||||||
faMicrochip,
|
faMicrochip,
|
||||||
faMagic
|
faMagic,
|
||||||
|
faQrcode
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -391,7 +392,8 @@ export class IconsModule {
|
|||||||
faBrain,
|
faBrain,
|
||||||
faRobot,
|
faRobot,
|
||||||
faMicrochip,
|
faMicrochip,
|
||||||
faMagic
|
faMagic,
|
||||||
|
faQrcode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260514',
|
'version': '260515',
|
||||||
'hash': 'g671489768'
|
'hash': 'g595054b0b'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user