From 4322f37c6d8784ecf3dc34b19f8a4baaab3b2f07 Mon Sep 17 00:00:00 2001 From: Aditya Date: Fri, 15 May 2026 17:45:07 +0530 Subject: [PATCH] feat(mfa): Google Authenticator (TOTP) + branded 2FA login popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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) --- .claude/settings.local.json | 4 +- .../access-control.component.html | 48 ++-- .../access-control.component.scss | 52 +++++ .../access-control.component.ts | 215 +++++++++++++----- .../access-control/access-control.service.ts | 39 ++++ .../two-factor-authentication.component.html | 65 ++++-- .../two-factor-authentication.component.scss | 207 +++++++++++++---- .../two-factor-authentication.component.ts | 7 + src/app/shared/icons.module.ts | 6 +- src/environments/.env.ts | 4 +- 10 files changed, 500 insertions(+), 147 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 341623986..89e03ff86 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 /i \"error neft-rtgs 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\")" ] } } diff --git a/src/app/access-control/access-control.component.html b/src/app/access-control/access-control.component.html index c65d59420..cd4aad543 100644 --- a/src/app/access-control/access-control.component.html +++ b/src/app/access-control/access-control.component.html @@ -622,32 +622,36 @@

- @if (!totp.secret && !totp.verified) { + @if (!totp.qrDataUri && !totp.verified) {
qr_code_scanner
No authenticator linked yet -

Generate a setup key, scan it in your TOTP app, then verify the 6-digit code shown there.

+

+ Click Set up to generate a QR for Google Authenticator (or any TOTP app), then enter + the 6-digit code to activate. +

-
- } @else if (totp.secret && !totp.verified) { + } @else if (totp.qrDataUri && !totp.verified) {
-
- @for (row of totpQrCells; track $index) { -
- @for (cell of row; track $index) { - - } -
- } -
+ Authenticator setup QR

Scan in your authenticator app

+

+ Issuer: {{ totp.issuer }} · {{ totp.accountName }} +

+ @if (totp.otpAuthUrl) { + + smartphone + On a phone? Tap to add to your authenticator + + }
@@ -658,7 +662,7 @@
2
- Scan the code on the left, or enter the secret manually. + Scan the code on the left. Can't scan? Enter this in your app:
{{ totp.secret }}
3 -
Enter the 6-digit code shown by your app to verify and register this device.
+
Enter the 6-digit code shown by your app to verify and activate.
@@ -690,9 +694,9 @@
-
@@ -702,11 +706,11 @@
check_circle
- Authenticator app registered - Sign-in will require a 6-digit TOTP code from your app. + Google Authenticator linked + Sign-in will require a 6-digit code from your authenticator app.
-
} diff --git a/src/app/access-control/access-control.component.scss b/src/app/access-control/access-control.component.scss index 2b9e1e9e6..c32fb5944 100644 --- a/src/app/access-control/access-control.component.scss +++ b/src/app/access-control/access-control.component.scss @@ -1131,6 +1131,58 @@ 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 { display: flex; flex-direction: column; diff --git a/src/app/access-control/access-control.component.ts b/src/app/access-control/access-control.component.ts index 8c36fcb7a..c1b0138c6 100644 --- a/src/app/access-control/access-control.component.ts +++ b/src/app/access-control/access-control.component.ts @@ -63,9 +63,24 @@ interface OtpChannelState { interface TotpState { typeCode: 'TOTP'; + /** Base-32 shared secret returned by /mfa/totp/setup (manual-entry fallback). */ secret: string; + /** `data:image/png;base64,...` — bound directly to . 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; + /** True once the factor is persisted on the server. */ verified: boolean; + /** Persisted factor id for disable. */ + factorId: number | null; + /** ISO timestamp from the server. */ + verifiedOn: string; errorMsg: string; busy: boolean; } @@ -142,14 +157,7 @@ export class AccessControlComponent implements OnInit, OnDestroy { email: OtpChannelState = this.makeOtpState('EMAIL'); /** Section 2 — Authenticator app (TOTP) */ - totp: TotpState = { - typeCode: 'TOTP', - secret: '', - otp: '', - verified: false, - errorMsg: '', - busy: false - }; + totp: TotpState = this.makeTotpState(); /** Section 3 — Hardware Token (Smart Card / DSC / Biometric) */ hardware: HardwareItem[] = [ @@ -310,9 +318,19 @@ export class AccessControlComponent implements OnInit, OnDestroy { this.applyFactor(this.sms, f); } else if (f.factorType === 'EMAIL') { this.applyFactor(this.email, f); - } else if (f.factorType === 'TOTP' && f.verified) { - this.totp.secret = f.secret || this.totp.secret; - this.totp.verified = true; + } else if (f.factorType === 'TOTP') { + // Verified+active TOTP means the user already has Google Authenticator + // 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') { // 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). @@ -359,6 +377,12 @@ export class AccessControlComponent implements OnInit, OnDestroy { this.email.verifiedOn = ''; this.totp.verified = false; 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) { h.registered = false; h.serial = ''; @@ -608,47 +632,45 @@ export class AccessControlComponent implements OnInit, OnDestroy { // ───────────────────────────── Authenticator app (TOTP) - generateTotpSecret(): void { - this.totp.secret = this.randomBase32(16); - this.totp.otp = ''; - this.totp.verified = false; + /** + * Begin TOTP enrolment: ask the backend to mint a secret + QR. Populates + * `qrDataUri`, `secret`, `otpAuthUrl`, `issuer` from the response so the + * 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 = ''; - } - - /** otpauth URI shown for manual entry / QR encoding by the user's authenticator app */ - get totpOtpauthUri(): string { - if (!this.totp.secret) return ''; - const issuer = 'CBSNexora'; - const label = `${issuer}:${this.username}`; - return `otpauth://totp/${encodeURIComponent(label)}?secret=${this.totp.secret}&issuer=${encodeURIComponent(issuer)}`; - } - - /** Visual placeholder QR — deterministic 8x8 grid from the secret string */ - get totpQrCells(): boolean[][] { - if (!this.totp.secret) return []; - const grid: boolean[][] = []; - let seed = 0; - for (let i = 0; i < this.totp.secret.length; i++) { - seed = (seed * 31 + this.totp.secret.charCodeAt(i)) & 0xffffffff; - } - for (let r = 0; r < 8; r++) { - const row: boolean[] = []; - for (let c = 0; c < 8; c++) { - // Three corner finder squares - const corner = - (r < 3 && c < 3) || (r < 3 && c > 4) || (r > 4 && c < 3) - ? r === 0 || r === 2 || r === 5 || r === 7 || c === 0 || c === 2 || c === 5 || c === 7 - : null; - if (corner !== null) { - row.push(corner); + this.totp.busy = true; + this.accessControlService.setupTotp().subscribe({ + next: (res) => { + this.totp.busy = false; + this.totp.qrDataUri = res?.qrDataUri || ''; + this.totp.secret = res?.secret || ''; + this.totp.otpAuthUrl = res?.otpAuthUrl || ''; + this.totp.issuer = res?.issuer || 'CBSNexora'; + this.totp.accountName = res?.accountName || this.username; + this.totp.otp = ''; + this.totp.verified = false; + this.totp.factorId = null; + this.totp.verifiedOn = ''; + }, + error: (err) => { + this.totp.busy = false; + const apiMsg = + err?.error?.userMessageGlobalisationCode || err?.error?.defaultUserMessage || err?.error?.message; + if (apiMsg === 'error.msg.user.mfa.totp.already.enrolled' || /already.enrolled/i.test(apiMsg || '')) { + this.showError( + 'Google Authenticator is already enrolled. Disable the existing factor before setting it up again.' + ); + // Re-hydrate so the UI matches the server's reality. + this.hydrateFromServer(); } else { - seed = (seed * 1103515245 + 12345) & 0x7fffffff; - row.push((seed & 1) === 1); + this.showError(apiMsg || 'Could not start TOTP setup. Try again.'); } } - grid.push(row); - } - return grid; + }); } 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 { const code = (this.totp.otp || '').trim(); if (!/^\d{6}$/.test(code)) { @@ -668,25 +695,72 @@ export class AccessControlComponent implements OnInit, OnDestroy { } this.totp.errorMsg = ''; this.totp.busy = true; - setTimeout(() => { - this.totp.busy = false; - this.totp.verified = true; - this.savePrefs(); - }, 500); + this.accessControlService.verifyTotp(code).subscribe({ + next: (res) => { + this.totp.busy = false; + if (!res?.verified) { + 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 { - this.totp = { typeCode: 'TOTP', secret: '', otp: '', verified: false, errorMsg: '', busy: false }; - this.savePrefs(); + if (!this.totp.verified) { + 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 { - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - let out = ''; - for (let i = 0; i < len; i++) { - out += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); - } - return out; + /** Mobile deep-link: tapping this on a phone hands the secret off to the authenticator. */ + get totpDeepLink(): string { + return this.totp.otpAuthUrl || ''; } // ───────────────────────────── Hardware Token @@ -1010,6 +1084,23 @@ export class AccessControlComponent implements OnInit, OnDestroy { // ───────────────────────────── 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 { return { typeCode, diff --git a/src/app/access-control/access-control.service.ts b/src/app/access-control/access-control.service.ts index 10c73be32..197c3eb09 100644 --- a/src/app/access-control/access-control.service.ts +++ b/src/app/access-control/access-control.service.ts @@ -24,6 +24,28 @@ export interface OtpVerifyResponse { 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 . */ + 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 { id?: number; factorType: MfaFactorType; @@ -96,4 +118,21 @@ export class AccessControlService { 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 { + return this.http.post('/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 { + return this.http.post('/v1/mfa/totp/verify', { code }, { headers: this.silentHeaders }); + } } diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.html b/src/app/login/two-factor-authentication/two-factor-authentication.component.html index 95663db2d..fcfb99a47 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.html +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.html @@ -7,6 +7,15 @@ -->
+ +
+ +
+ CBSNexora + Secure Sign-in +
+
+
@@ -17,6 +26,8 @@ Plug in your DSC token and follow the prompts in the helper. } @else if (!otpRequested) { Choose how you want to verify your identity. + } @else if (isSelectedTotp) { + Open your authenticator app and enter the 6-digit code below. } @else { Enter the 6-digit code we sent you to continue. } @@ -140,13 +151,25 @@
- Code sent to - {{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }} + @if (isSelectedTotp) { + Open your authenticator app + Enter the 6-digit code + } @else { + Code sent to + {{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }} + }
- - - {{ tokenValidityTime / 60 }} {{ 'labels.inputs.mins' | translate }} - + @if (!isSelectedTotp) { + + + {{ tokenValidityTime / 60 }} {{ 'labels.inputs.mins' | translate }} + + } @else { + + + rotates every 30 s + + }
@@ -184,20 +207,22 @@ } - + @if (!isSelectedTotp) { + + }
}
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss index e76036d5f..553d96cc5 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss @@ -15,38 +15,135 @@ .tfa-card { width: 100%; - max-width: 400px; - padding: 24px 22px 22px; - border-radius: 18px; - background: rgb(255 255 255 / 6%); - border: 1px solid rgb(255 255 255 / 12%); - backdrop-filter: blur(8px); + max-width: 420px; + 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; } +/* 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 { align-self: center; - width: 56px; - height: 56px; - border-radius: 16px; + 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: 24px; + font-size: 26px; 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%); 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 { margin: 0 0 6px; text-align: center; - font-size: 18px; + font-size: 19px; font-weight: 700; color: #fff; letter-spacing: 0.2px; @@ -56,7 +153,7 @@ margin: 0 0 20px; text-align: center; font-size: 13px; - color: rgb(255 255 255 / 65%); + color: rgb(255 255 255 / 70%); line-height: 1.5; } @@ -258,41 +355,58 @@ } } -/* ──────── Step 2 — OTP input ──────── */ +/* ──────── Step 2 — OTP input (segmented box look) ──────── */ .tfa-otp { display: flex; justify-content: center; } .tfa-otp__input { - width: 100%; - max-width: 320px; - height: 60px; - text-align: center; - border: 1px solid rgb(255 255 255 / 18%); - background: rgb(255 255 255 / 8%); - border-radius: 14px; + --cell-w: 46px; + --cell-gap: 10px; + + width: calc(var(--cell-w) * 6 + var(--cell-gap) * 5); + max-width: 100%; + height: 56px; + 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; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; - font-size: 28px; + font-size: 24px; 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; + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 14%), + 0 4px 14px rgb(0 0 0 / 28%); transition: - border-color 0.18s, box-shadow 0.18s, background 0.18s; &::placeholder { - color: rgb(255 255 255 / 25%); - letter-spacing: 14px; + color: rgb(255 255 255 / 28%); } &:focus { - border-color: #60a5fa; - background: rgb(255 255 255 / 12%); - box-shadow: 0 0 0 4px rgb(96 165 250 / 25%); + box-shadow: + inset 0 0 0 1.5px #60a5fa, + 0 0 0 4px rgb(96 165 250 / 22%), + 0 4px 14px rgb(0 0 0 / 28%); } } @@ -349,17 +463,24 @@ } &--primary { - background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 60%, #1e3a8a 100%); color: #fff; box-shadow: - 0 6px 20px rgb(37 99 235 / 32%), - inset 0 1px 0 rgb(255 255 255 / 18%); + 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 10px 24px rgb(37 99 235 / 42%), - inset 0 1px 0 rgb(255 255 255 / 22%); + 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%); } } @@ -396,13 +517,23 @@ /* ──────── Responsive ──────── */ @media (width <= 480px) { .tfa-card { - padding: 20px 16px; + padding: 18px 16px 20px; border-radius: 16px; } .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; - letter-spacing: 10px; - padding-left: 10px; } } diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts index 1f6106f4b..c1ad67fb5 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts @@ -210,10 +210,17 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy { iconFor(method: any): string { const name = (method?.name || '').toLowerCase(); 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'; 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. */ requestDscChallenge(method: any): void { this.loading = true; diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 2e3a3e2fd..85e048995 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -192,7 +192,8 @@ import { faBrain, faRobot, faMicrochip, - faMagic + faMagic, + faQrcode } from '@fortawesome/free-solid-svg-icons'; /** @@ -391,7 +392,8 @@ export class IconsModule { faBrain, faRobot, faMicrochip, - faMagic + faMagic, + faQrcode ); } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index c54e04647..21b460bcf 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260514', - 'hash': 'g671489768' + 'version': '260515', + 'hash': 'g595054b0b' }, 'allow_switching_backend_instance': true };