This commit is contained in:
Chris Chen
2026-05-25 17:32:18 -07:00
parent 9b28fbcfb6
commit d5648315a0
262 changed files with 32074 additions and 0 deletions
@@ -0,0 +1,88 @@
<div class="mfa-dialog-overlay" *ngIf="visible" (click)="close()">
<div class="mfa-dialog-container" (click)="$event.stopPropagation()">
<!-- Background Elements -->
<div class="dialog-background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
</div>
<!-- Dialog Content -->
<div class="mfa-dialog-content">
<!-- Header -->
<div class="mfa-header">
<button class="close-button" (click)="close()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div class="header-content">
<div class="mfa-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<circle cx="12" cy="16" r="1"></circle>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3>Two-Factor Authentication</h3>
<p>Enter the 6-digit code sent to your device</p>
</div>
</div>
<!-- MFA Code Input -->
<div class="mfa-code-section">
<div class="code-inputs-container">
<ng-container *ngFor="let code of userInputCodes2; let i = index">
<input #codeInput type="number" min="0" max="9" maxlength="1" class="mfa-input"
[(ngModel)]="userInputCodes2[i]" name="n{{i+1}}" (keydown)="onKeyDown(i,$event)"
(paste)="pasteCode(i,$event)" [autofocus]="i==0"
[attr.aria-label]="'MFA code digit ' + (i+1)" title="Enter MFA code digit {{i+1}}">
</ng-container>
</div>
<!-- Error Message -->
<div *ngIf="isInvalidCode" class="error-message">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
Invalid code. Please try again.
</div>
</div>
<!-- Actions -->
<div class="mfa-actions">
<button kendoButton type="button" themeColor="secondary" size="medium" (click)="resendMFCode()"
[disabled]="processing || resendCountDown > 0" class="resend-button">
<span class="button-content">
<svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23,4 23,10 17,10"></polyline>
<polyline points="1,20 1,14 7,14"></polyline>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
{{ resendCodeText }}
</span>
</button>
<button kendoButton type="button" themeColor="primary" size="large" (click)="submitCode()"
[disabled]="processing || !allowSubmit" class="verify-button">
<span class="button-content">
<kendo-loader *ngIf="processing" size="small"></kendo-loader>
<svg *ngIf="!processing" class="button-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
{{ processing ? 'Verifying...' : 'Verify Code' }}
</span>
</button>
</div>
<!-- Help Text -->
<div class="help-text">
<p>Didn't receive the code? Check your spam folder or try resending.</p>
</div>
</div>
</div>
</div>
@@ -0,0 +1,418 @@
.mfa-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.mfa-dialog-container {
position: relative;
width: 100%;
max-width: 450px;
background: rgba(255, 255, 255, 0.98);
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
overflow: hidden;
animation: dialogSlideIn 0.3s ease-out;
}
@keyframes dialogSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Background Shapes
.dialog-background-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
border-radius: 50%;
background: rgba(30, 64, 175, 0.05);
animation: float 8s ease-in-out infinite;
&.shape-1 {
width: 80px;
height: 80px;
top: 15%;
right: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 60px;
height: 60px;
bottom: 20%;
left: 15%;
animation-delay: 3s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-15px) rotate(180deg);
}
}
// Dialog Content
.mfa-dialog-content {
position: relative;
z-index: 1;
padding: 2rem;
}
// Header
.mfa-header {
position: relative;
text-align: center;
margin-bottom: 2rem;
.close-button {
position: absolute;
top: -0.5rem;
right: -0.5rem;
background: #f8f9fa;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: #6b7280;
&:hover {
background: #e5e7eb;
color: #374151;
transform: scale(1.1);
}
svg {
width: 16px;
height: 16px;
}
}
.header-content {
.mfa-icon {
width: 60px;
height: 60px;
margin: 0 auto 1rem;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
svg {
width: 28px;
height: 28px;
}
}
h3 {
font-size: 1.75rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
p {
color: #6b7280;
font-size: 1rem;
margin: 0;
line-height: 1.5;
}
}
}
// MFA Code Section
.mfa-code-section {
margin-bottom: 2rem;
.code-inputs-container {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-bottom: 1rem;
.mfa-input {
width: 50px;
height: 50px;
border: 2px solid #e5e7eb;
border-radius: 12px;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
background: #ffffff;
transition: all 0.2s ease;
outline: none;
&:focus {
border-color: #1e40af;
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
transform: scale(1.05);
}
&:hover:not(:focus) {
border-color: #d1d5db;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type="number"] {
-moz-appearance: textfield;
}
}
}
.error-message {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: #fee2e2;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid #fecaca;
font-size: 0.9rem;
font-weight: 500;
animation: errorShake 0.5s ease-in-out;
.error-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
}
@keyframes errorShake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
// Actions
.mfa-actions {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
.resend-button {
flex: 1;
height: 48px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
background: #f8f9fa;
border: 2px solid #e5e7eb;
color: #6b7280;
transition: all 0.3s ease;
&:hover:not(:disabled) {
background: #e9ecef;
border-color: #d1d5db;
color: #495057;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 16px;
height: 16px;
}
}
.verify-button {
flex: 2;
height: 48px;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
color: white;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(30, 58, 138, 0.4);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 18px;
height: 18px;
}
}
}
// Help Text
.help-text {
text-align: center;
p {
color: #9ca3af;
font-size: 0.85rem;
margin: 0;
line-height: 1.4;
}
}
// Mobile Responsive
@media (max-width: 480px) {
.mfa-dialog-overlay {
padding: 0.5rem;
}
.mfa-dialog-container {
max-width: 100%;
border-radius: 16px;
}
.mfa-dialog-content {
padding: 1.5rem;
}
.mfa-header {
margin-bottom: 1.5rem;
.header-content {
.mfa-icon {
width: 50px;
height: 50px;
margin-bottom: 0.75rem;
svg {
width: 24px;
height: 24px;
}
}
h3 {
font-size: 1.5rem;
}
p {
font-size: 0.9rem;
}
}
}
.mfa-code-section {
margin-bottom: 1.5rem;
.code-inputs-container {
gap: 0.5rem;
.mfa-input {
width: 45px;
height: 45px;
font-size: 1.25rem;
}
}
}
.mfa-actions {
flex-direction: column;
gap: 0.75rem;
.resend-button,
.verify-button {
flex: 1;
height: 44px;
}
.verify-button {
order: -1;
}
}
}
@media (max-width: 360px) {
.mfa-dialog-content {
padding: 1rem;
}
.code-inputs-container {
gap: 0.4rem;
.mfa-input {
width: 40px;
height: 40px;
font-size: 1.1rem;
}
}
}
@@ -0,0 +1,250 @@
import { Component, ElementRef, QueryList, ViewChildren, Output, EventEmitter, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { AuthService, LoginCredentials, LoginResultType } from '../services/auth.service';
import { take } from 'rxjs/operators';
const CODE_LENGTH = 6;
@Component({
selector: 'app-mfa-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ButtonsModule,
IndicatorsModule
],
templateUrl: './mfa-dialog.component.html',
styleUrls: ['./mfa-dialog.component.scss']
})
export class MfaDialogComponent {
@ViewChildren('codeInput') codeInputs!: QueryList<ElementRef>;
@Output() mfaSuccess = new EventEmitter<any>();
@Output() mfaCancel = new EventEmitter<void>();
@Input() visible = false;
token: string = '';
userInputCodes: (string | null)[] = [];
userInputCodes2: (string | null)[] = [];
loginData!: LoginCredentials;
processing = false;
allowSubmit = false;
isInvalidCode = false;
resendCountDown = 30;
constructor(private authService: AuthService) { }
ngOnInit() {
for (let i = 0; i < CODE_LENGTH; i++) {
this.userInputCodes.push(null);
this.userInputCodes2.push(null);
}
this.setReSendCountDown();
}
pasteCode(index: number, event: ClipboardEvent) {
event.preventDefault();
const data = event.clipboardData?.getData('text/plain') || '';
let pasteCode = data.replace(new RegExp("[^0-9]", 'g'), "");
for (let i = index; i < CODE_LENGTH; i++) {
if (pasteCode.length > i) {
const code = pasteCode[i];
let input = this.codeInputs.find((element, j) => j === i);
if (input) {
input.nativeElement.value = code;
this.userInputCodes[i] = code;
}
}
}
this.validate(5);
}
public onKeyDown(index: number, e: KeyboardEvent): void {
const el: HTMLInputElement = e.target as HTMLInputElement;
if (e.ctrlKey && e.key.toUpperCase() == 'V') {
return;
} else {
let nextFocusInput: ElementRef | undefined = undefined;
switch (e.key) {
case 'ArrowLeft':
nextFocusInput = this.getInputElements(index, -1);
if (nextFocusInput) nextFocusInput.nativeElement.focus();
break;
case 'ArrowRight':
nextFocusInput = this.getInputElements(index, 1);
if (nextFocusInput) nextFocusInput.nativeElement.focus();
break;
case 'Backspace':
if (el.value) {
el.value = '';
} else {
nextFocusInput = this.getInputElements(index, -1);
}
break;
case 'Delete':
el.value = '';
break;
default:
break;
}
if (nextFocusInput) {
nextFocusInput.nativeElement.focus();
return;
}
let isReplacing = el.selectionStart != el.selectionEnd;
if (this.isEditingKeyPress(e)) {
e.preventDefault();
if (new RegExp("[0-9]", 'g').test(e.key)) {
this.userInputCodes[index] = e.key;
el.value = e.key;
let nextInput = this.getInputElements(index, 1);
if (nextInput) nextInput.nativeElement.focus();
} else {
el.value = '';
this.userInputCodes[index] = null;
}
}
}
this.isInvalidCode = false;
this.validate(index);
}
private getInputElements(index: number, indexOffset: number): ElementRef | undefined {
let nextInput = this.codeInputs.find((element, i) => i === (index + indexOffset));
return nextInput;
}
private isEditingKeyPress(e: KeyboardEvent): boolean {
return e.key.length === 1 || e.key === 'Backspace' || e.key === 'Delete' || e.key === 'ArrowLeft' || e.key === 'ArrowRight';
}
validate(focusIndex: number) {
this.allowSubmit = !this.userInputCodes.some(n => n == null);
this.token = this.userInputCodes.map(n => n != null ? n : '').join('');
if (this.token && this.token.length == 6 && focusIndex == 5) {
this.submitCode();
}
}
close() {
this.visible = false;
this.mfaCancel.emit();
}
show() {
this.visible = true;
this.resetForm();
}
hide() {
this.visible = false;
}
resetForm() {
this.userInputCodes = [];
this.userInputCodes2 = [];
this.token = '';
this.isInvalidCode = false;
this.processing = false;
this.allowSubmit = false;
for (let i = 0; i < CODE_LENGTH; i++) {
this.userInputCodes.push(null);
this.userInputCodes2.push(null);
}
// Focus on first input
setTimeout(() => {
const firstInput = document.querySelector('.mfa-input:first-child') as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
}, 100);
}
submitCode() {
this.processing = true;
this.loginData.mfaCode = this.token;
// Check if this is token-based authentication
if ((this.loginData as any).tokenUser) {
// Handle token-based MFA verification
this.authService.verifyMfaForToken(this.token, (this.loginData as any).tokenUser).subscribe({
next: (result) => {
this.processing = false;
if (result.result === LoginResultType.Success) {
this.mfaSuccess.emit(result.responseData);
this.visible = false;
} else {
this.isInvalidCode = true;
}
},
error: (error) => {
this.processing = false;
this.isInvalidCode = true;
console.error('MFA verification error:', error);
}
});
} else {
// Handle regular login MFA verification
this.authService.login(this.loginData).subscribe({
next: (result) => {
this.processing = false;
if (result.result === LoginResultType.Success) {
this.mfaSuccess.emit(result.responseData);
this.visible = false;
} else {
this.isInvalidCode = true;
}
},
error: (error) => {
this.processing = false;
this.isInvalidCode = true;
console.error('MFA verification error:', error);
}
});
}
}
setReSendCountDown() {
if (this.resendCountDown > 0) {
setTimeout(() => {
this.resendCountDown--;
this.setReSendCountDown();
}, 1000);
}
}
resendMFCode() {
this.resendCountDown = 30;
this.loginData.mfaCode = '';
// Simulate resend MFA code - replace with actual service call
console.log('Resending MFA code to:', this.loginData.email);
// Check if this is token-based authentication
if ((this.loginData as any).tokenUser) {
// Handle token-based MFA verification
this.authService.verifyMfaForToken(this.token, (this.loginData as any).tokenUser).pipe(
take(1)
).subscribe(result => {
this.setReSendCountDown();
});
} else {
//TODO: Implement resend MFA code for regular login
}
this.setReSendCountDown();
}
public get resendCodeText(): string {
return 'Resend Code' + (this.resendCountDown > 0 ? ` (${this.resendCountDown})` : '');
}
}