WIP
This commit is contained in:
@@ -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})` : '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Export user models
|
||||
export * from './user.model';
|
||||
@@ -0,0 +1,400 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { ApiConfigService } from '../../core/services/api-config.service';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: string;
|
||||
branchIds: string[];
|
||||
token: string;
|
||||
mfaVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
mfaCode?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
isAuthenticated: boolean;
|
||||
requiresMfa: boolean;
|
||||
token?: string;
|
||||
expires?: string;
|
||||
user?: UserDto;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TokenCreateResponse {
|
||||
token?: string;
|
||||
mfaToken?: string;
|
||||
access?: AccessDto[];
|
||||
isAuthenticated?: boolean;
|
||||
isAuthorized?: boolean;
|
||||
isChangePassword?: boolean;
|
||||
message?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
concurrentTabs?: number;
|
||||
mfaType?: number;
|
||||
mfaHint?: string;
|
||||
Token?: string;
|
||||
MfaToken?: string;
|
||||
Access?: AccessDto[];
|
||||
IsAuthenticated?: boolean;
|
||||
IsAuthorized?: boolean;
|
||||
IsChangePassword?: boolean;
|
||||
Message?: string;
|
||||
Username?: string;
|
||||
Email?: string;
|
||||
ConcurrentTabs?: number;
|
||||
MfaType?: number;
|
||||
MfaHint?: string;
|
||||
}
|
||||
|
||||
export interface AccessDto {
|
||||
branchName?: string;
|
||||
redxBranch?: string;
|
||||
BranchName?: string;
|
||||
RedxBranch?: string;
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: string;
|
||||
branchIds: string[];
|
||||
}
|
||||
|
||||
export enum LoginResultType {
|
||||
Success = 'Success',
|
||||
MfaRequired = 'MfaRequired',
|
||||
InvalidCredentials = 'InvalidCredentials',
|
||||
Error = 'Error'
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
result: LoginResultType;
|
||||
responseData?: User;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TokenVerificationResult {
|
||||
isValid: boolean;
|
||||
user?: User;
|
||||
message?: string;
|
||||
expiresAt?: Date;
|
||||
requiresMfa?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private currentUserSubject = new BehaviorSubject<User | null>(null);
|
||||
public currentUser$ = this.currentUserSubject.asObservable();
|
||||
private redirectUrl: string = '/dashboard';
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private apiConfig: ApiConfigService
|
||||
) { }
|
||||
|
||||
login(credentials: LoginCredentials): Observable<LoginResult> {
|
||||
return this.authenticateUser(credentials);
|
||||
}
|
||||
|
||||
private authenticateUser(credentials: LoginCredentials): Observable<LoginResult> {
|
||||
const loginUrl = `${this.apiConfig.tokenUrl}/Create`;
|
||||
return this.http.get<TokenCreateResponse>(loginUrl, {
|
||||
headers: this.buildTokenCreateHeaders(credentials)
|
||||
}).pipe(
|
||||
map((response: TokenCreateResponse) => this.mapTokenCreateResponse(response, credentials)),
|
||||
catchError((error) => {
|
||||
console.error('Login error:', error);
|
||||
return of({
|
||||
result: LoginResultType.Error,
|
||||
message: error.error?.message || 'An error occurred during login'
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private buildTokenCreateHeaders(credentials: LoginCredentials): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
Authorization: `Basic ${btoa(`${credentials.email}:${credentials.password}`)}`
|
||||
});
|
||||
|
||||
if (credentials.mfaCode) {
|
||||
headers = headers.set('mfaCode', credentials.mfaCode);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private mapTokenCreateResponse(response: TokenCreateResponse, credentials: LoginCredentials): LoginResult {
|
||||
const token = response.token || response.Token || '';
|
||||
const message = response.message || response.Message || '';
|
||||
const isAuthenticated = response.isAuthenticated ?? response.IsAuthenticated ?? false;
|
||||
const isAuthorized = response.isAuthorized ?? response.IsAuthorized ?? false;
|
||||
|
||||
if (isAuthenticated && isAuthorized && token) {
|
||||
return {
|
||||
result: LoginResultType.Success,
|
||||
responseData: this.mapUserFromTokenCreateResponse(response, credentials, token)
|
||||
};
|
||||
}
|
||||
|
||||
if (isAuthenticated && !token && this.isMfaRequired(message)) {
|
||||
return {
|
||||
result: LoginResultType.MfaRequired,
|
||||
responseData: this.mapUserFromTokenCreateResponse(
|
||||
response,
|
||||
credentials,
|
||||
response.mfaToken || response.MfaToken || ''
|
||||
),
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: isAuthenticated ? LoginResultType.Error : LoginResultType.InvalidCredentials,
|
||||
message: message || 'Invalid email or password'
|
||||
};
|
||||
}
|
||||
|
||||
private mapUserFromTokenCreateResponse(
|
||||
response: TokenCreateResponse,
|
||||
credentials: LoginCredentials,
|
||||
token: string
|
||||
): User {
|
||||
const username = response.username || response.Username || credentials.email;
|
||||
const email = response.email || response.Email || credentials.email;
|
||||
const access = response.access || response.Access || [];
|
||||
|
||||
return {
|
||||
id: username || email,
|
||||
username,
|
||||
email,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
branchIds: access
|
||||
.map(item => item.redxBranch || item.RedxBranch || item.branchName || item.BranchName || '')
|
||||
.filter(branchId => !!branchId),
|
||||
token,
|
||||
mfaVerified: !!credentials.mfaCode
|
||||
};
|
||||
}
|
||||
|
||||
private isMfaRequired(message: string): boolean {
|
||||
return message.toLowerCase().includes('mfa verification required');
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.currentUserSubject.next(null);
|
||||
this.redirectUrl = '/dashboard';
|
||||
// Clear any stored tokens, etc.
|
||||
localStorage.removeItem('currentUser');
|
||||
}
|
||||
|
||||
getCurrentUser(): User | null {
|
||||
return this.currentUserSubject.value;
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
const user = this.currentUserSubject.value;
|
||||
return user?.token || null;
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return this.currentUserSubject.value !== null;
|
||||
}
|
||||
|
||||
setCurrentUser(user: User): void {
|
||||
this.currentUserSubject.next(user);
|
||||
// Store user in localStorage for persistence
|
||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||
}
|
||||
|
||||
setRedirectUrl(url: string): void {
|
||||
this.redirectUrl = url;
|
||||
}
|
||||
|
||||
getRedirectUrl(): string {
|
||||
return this.redirectUrl;
|
||||
}
|
||||
|
||||
// Initialize user from localStorage on app start
|
||||
initializeAuth(): void {
|
||||
const storedUser = localStorage.getItem('currentUser');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const user = JSON.parse(storedUser);
|
||||
this.currentUserSubject.next(user);
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored user:', error);
|
||||
localStorage.removeItem('currentUser');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token from email link (local verification)
|
||||
* @param token The JWT token to verify
|
||||
* @returns Observable with verification result
|
||||
*/
|
||||
verifySecretLinkToken(token: string): Observable<TokenVerificationResult> {
|
||||
try {
|
||||
// Parse JWT token locally
|
||||
const tokenData = this.parseJwtToken(token);
|
||||
|
||||
if (!tokenData) {
|
||||
return of({
|
||||
isValid: false,
|
||||
message: 'Invalid token format'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (this.isTokenExpired(token)) {
|
||||
return of({
|
||||
isValid: false,
|
||||
message: 'This link has expired. Please request a new one.'
|
||||
});
|
||||
}
|
||||
console.log('tokenData', tokenData);
|
||||
|
||||
// Extract user data from token
|
||||
const user: User = {
|
||||
id: tokenData.userId || tokenData.sub || tokenData.id,
|
||||
username: tokenData.username || tokenData.preferred_username || '',
|
||||
email: tokenData.email || tokenData.email_address || '',
|
||||
firstName: tokenData.firstName || tokenData.given_name || tokenData.first_name || '',
|
||||
lastName: tokenData.lastName || tokenData.family_name || tokenData.last_name || '',
|
||||
createdAt: tokenData.createdAt || tokenData.created_at || new Date().toISOString(),
|
||||
branchIds: tokenData.branchIds || tokenData.branch_ids || [],
|
||||
token: token,
|
||||
mfaVerified: false // Token users still need MFA verification
|
||||
};
|
||||
|
||||
return of({
|
||||
isValid: true,
|
||||
user: user,
|
||||
message: 'Token verified successfully. MFA required.',
|
||||
expiresAt: tokenData.exp ? new Date(tokenData.exp * 1000) : undefined,
|
||||
requiresMfa: true
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return of({
|
||||
isValid: false,
|
||||
message: 'Invalid or corrupted token'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired locally (basic check)
|
||||
* @param token JWT token to check
|
||||
* @returns boolean indicating if token is expired
|
||||
*/
|
||||
isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < currentTime;
|
||||
} catch (error) {
|
||||
console.error('Error parsing token:', error);
|
||||
return true; // Consider invalid tokens as expired
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JWT token and extract payload
|
||||
* @param token JWT token to parse
|
||||
* @returns parsed token data or null if invalid
|
||||
*/
|
||||
private parseJwtToken(token: string): any | null {
|
||||
try {
|
||||
// Split token into parts
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode the payload (middle part)
|
||||
const payload = parts[1];
|
||||
const decodedPayload = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(decodedPayload);
|
||||
} catch (error) {
|
||||
console.error('Error parsing JWT token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA code for token-based authentication
|
||||
* @param mfaCode MFA code entered by user
|
||||
* @param user User data from token verification
|
||||
* @returns Observable with MFA verification result
|
||||
*/
|
||||
verifyMfaForToken(mfaCode: string, user: User): Observable<LoginResult> {
|
||||
// For token-based users, we can either:
|
||||
// 1. Verify MFA locally (if MFA code is embedded in token)
|
||||
// 2. Make a server call to verify MFA
|
||||
|
||||
// For now, we'll simulate MFA verification
|
||||
// In a real implementation, you might want to verify against a server
|
||||
//TODO: Implement MFA verification
|
||||
|
||||
const loginUrl = `${this.apiConfig.authUrl}/mfa/token-login`;
|
||||
return this.http.post<LoginResponse>(loginUrl, {
|
||||
mfaToken: user.token,
|
||||
mfaCode: mfaCode
|
||||
}).pipe(
|
||||
map((response: LoginResponse) => {
|
||||
|
||||
return {
|
||||
result: LoginResultType.Success,
|
||||
responseData: {
|
||||
...user,
|
||||
mfaVerified: true
|
||||
},
|
||||
message: 'MFA verification successful'
|
||||
};
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('MFA verification error:', error);
|
||||
return of({
|
||||
result: LoginResultType.Error,
|
||||
message: error.error?.message || 'An error occurred during MFA verification'
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from URL parameters
|
||||
* @param url URL containing token parameter
|
||||
* @returns token string or null if not found
|
||||
*/
|
||||
extractTokenFromUrl(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.searchParams.get('token');
|
||||
} catch (error) {
|
||||
console.error('Error parsing URL:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { SVGIcon } from '@progress/kendo-angular-icons';
|
||||
import { EscrowStatus, CbAssigneeRole } from '../models/enums.model';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UiUtilsService {
|
||||
public setPageTitleSubject = new Subject<string>();
|
||||
public setPageTitle$ = this.setPageTitleSubject.asObservable();
|
||||
// Icon properties - these should be injected or provided by a parent component
|
||||
// For now, we'll make them optional and let the calling component provide them
|
||||
checkCircleIcon?: SVGIcon;
|
||||
clockIcon?: SVGIcon;
|
||||
alertCircleIcon?: SVGIcon;
|
||||
userIcon?: SVGIcon;
|
||||
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Get CSS class for escrow status
|
||||
*/
|
||||
getStatusClass(status: EscrowStatus): string {
|
||||
switch (status) {
|
||||
case EscrowStatus.Open:
|
||||
return 'status-active';
|
||||
case EscrowStatus.Closed:
|
||||
return 'status-completed';
|
||||
case EscrowStatus.Cancelled:
|
||||
return 'status-cancelled';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for escrow status
|
||||
*/
|
||||
getEscrowStatusLabel(status: EscrowStatus): string {
|
||||
switch (status) {
|
||||
case EscrowStatus.Open:
|
||||
return 'Open';
|
||||
case EscrowStatus.Closed:
|
||||
return 'Closed';
|
||||
case EscrowStatus.Cancelled:
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for priority level
|
||||
*/
|
||||
getPriorityClass(priority: string): string {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'priority-high';
|
||||
case 'medium':
|
||||
return 'priority-medium';
|
||||
case 'low':
|
||||
return 'priority-low';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for escrow status
|
||||
*/
|
||||
getStatusIcon(status: EscrowStatus): SVGIcon | undefined {
|
||||
switch (status) {
|
||||
case EscrowStatus.Open:
|
||||
return this.checkCircleIcon;
|
||||
case EscrowStatus.Closed:
|
||||
return this.checkCircleIcon;
|
||||
case EscrowStatus.Cancelled:
|
||||
return this.alertCircleIcon;
|
||||
default:
|
||||
return this.clockIcon;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for assignee role
|
||||
*/
|
||||
getRoleClass(role: CbAssigneeRole): string {
|
||||
switch (role) {
|
||||
case CbAssigneeRole.Buyer:
|
||||
return 'role-buyer';
|
||||
case CbAssigneeRole.Seller:
|
||||
return 'role-seller';
|
||||
case CbAssigneeRole.BuyerRealEstateAgent:
|
||||
case CbAssigneeRole.SellerRealEstateAgent:
|
||||
return 'role-agent';
|
||||
case CbAssigneeRole.EscrowOfficer:
|
||||
case CbAssigneeRole.EscrowAssignee:
|
||||
return 'role-escrow';
|
||||
case CbAssigneeRole.LoanBroker:
|
||||
case CbAssigneeRole.Lender:
|
||||
return 'role-lender';
|
||||
case CbAssigneeRole.SellerTransactionCoordinator:
|
||||
case CbAssigneeRole.BuyerTransactionCoordinator:
|
||||
return 'role-coordinator';
|
||||
case CbAssigneeRole.None:
|
||||
return 'role-default';
|
||||
default:
|
||||
return 'role-default';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for assignee role
|
||||
*/
|
||||
getRoleIcon(role: CbAssigneeRole): SVGIcon | undefined {
|
||||
switch (role) {
|
||||
case CbAssigneeRole.Buyer:
|
||||
case CbAssigneeRole.Seller:
|
||||
case CbAssigneeRole.BuyerRealEstateAgent:
|
||||
case CbAssigneeRole.SellerRealEstateAgent:
|
||||
case CbAssigneeRole.EscrowOfficer:
|
||||
case CbAssigneeRole.EscrowAssignee:
|
||||
case CbAssigneeRole.LoanBroker:
|
||||
case CbAssigneeRole.Lender:
|
||||
case CbAssigneeRole.SellerTransactionCoordinator:
|
||||
case CbAssigneeRole.BuyerTransactionCoordinator:
|
||||
return this.userIcon;
|
||||
case CbAssigneeRole.None:
|
||||
return this.userIcon;
|
||||
default:
|
||||
return this.userIcon;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for assignee role
|
||||
*/
|
||||
getRoleLabel(role: CbAssigneeRole): string {
|
||||
switch (role) {
|
||||
case CbAssigneeRole.Buyer:
|
||||
return 'Buyer';
|
||||
case CbAssigneeRole.Seller:
|
||||
return 'Seller';
|
||||
case CbAssigneeRole.BuyerRealEstateAgent:
|
||||
return 'Buyer Agent';
|
||||
case CbAssigneeRole.SellerRealEstateAgent:
|
||||
return 'Seller Agent';
|
||||
case CbAssigneeRole.EscrowOfficer:
|
||||
return 'Escrow Officer';
|
||||
case CbAssigneeRole.EscrowAssignee:
|
||||
return 'Escrow Assignee';
|
||||
case CbAssigneeRole.LoanBroker:
|
||||
return 'Loan Broker';
|
||||
case CbAssigneeRole.Lender:
|
||||
return 'Lender';
|
||||
case CbAssigneeRole.SellerTransactionCoordinator:
|
||||
return 'Seller Coordinator';
|
||||
case CbAssigneeRole.BuyerTransactionCoordinator:
|
||||
return 'Buyer Coordinator';
|
||||
case CbAssigneeRole.None:
|
||||
return 'None';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set icons for the service (should be called by components that use this service)
|
||||
*/
|
||||
setIcons(icons: {
|
||||
checkCircleIcon?: SVGIcon;
|
||||
clockIcon?: SVGIcon;
|
||||
alertCircleIcon?: SVGIcon;
|
||||
userIcon?: SVGIcon;
|
||||
}): void {
|
||||
this.checkCircleIcon = icons.checkCircleIcon;
|
||||
this.clockIcon = icons.clockIcon;
|
||||
this.alertCircleIcon = icons.alertCircleIcon;
|
||||
this.userIcon = icons.userIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// =============================================================================
|
||||
// UI Utility Classes
|
||||
// =============================================================================
|
||||
// Centralized SCSS for utility classes used by UiUtilsService
|
||||
// These classes provide consistent styling across all components
|
||||
|
||||
// =============================================================================
|
||||
// Status Badge Classes
|
||||
// =============================================================================
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
kendo-svgicon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
// Status variants
|
||||
&.status-active {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.status-cancelled {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Priority Badge Classes
|
||||
// =============================================================================
|
||||
.priority-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
// Priority variants
|
||||
&.priority-high {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.priority-medium {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.priority-low {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Role Badge Classes
|
||||
// =============================================================================
|
||||
.role-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
kendo-svgicon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
// Role variants
|
||||
&.role-buyer {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.role-seller {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #9333ea;
|
||||
}
|
||||
|
||||
&.role-agent {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.role-escrow {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.role-lender {
|
||||
background: rgba(139, 69, 19, 0.1);
|
||||
color: #8b4513;
|
||||
}
|
||||
|
||||
&.role-coordinator {
|
||||
background: rgba(75, 85, 99, 0.1);
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
&.role-default {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Task Status Classes (for transaction-detail component)
|
||||
// =============================================================================
|
||||
.task-status {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.task-completed {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.task-in-progress {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.task-pending {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.task-cancelled {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Mixins (for consistent styling)
|
||||
// =============================================================================
|
||||
@mixin badge-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@mixin status-colors($bg-color, $text-color) {
|
||||
background: rgba($bg-color, 0.1);
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Responsive Design
|
||||
// =============================================================================
|
||||
@media (max-width: 768px) {
|
||||
.status-badge,
|
||||
.role-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dark Mode Support (if needed in the future)
|
||||
// =============================================================================
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.status-badge {
|
||||
&.status-active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
&.status-cancelled {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
&.priority-high {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
&.priority-medium {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
&.priority-low {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
&.role-buyer {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
&.role-seller {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
&.role-agent {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
&.role-escrow {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
&.role-lender {
|
||||
background: rgba(139, 69, 19, 0.2);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.role-coordinator {
|
||||
background: rgba(75, 85, 99, 0.2);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&.role-default {
|
||||
background: rgba(107, 114, 128, 0.2);
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<div class="token-verification-container">
|
||||
<div class="verification-card">
|
||||
<div class="logo-section">
|
||||
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo">
|
||||
</div>
|
||||
|
||||
<div class="verification-content">
|
||||
<!-- Verifying State -->
|
||||
<div *ngIf="isVerifying" class="verifying-state">
|
||||
<kendo-loader size="large"></kendo-loader>
|
||||
<h2>Verifying your access...</h2>
|
||||
<p>Please wait while we verify your email link.</p>
|
||||
</div>
|
||||
|
||||
<!-- Token Verified State -->
|
||||
<div *ngIf="verificationResult?.isValid && !isVerifying && !verificationResult?.requiresMfa"
|
||||
class="success-state">
|
||||
<div class="success-icon">
|
||||
<i class="k-icon k-i-check-circle"></i>
|
||||
</div>
|
||||
<h2>Access Granted!</h2>
|
||||
<p>Welcome back, {{ verificationResult?.user?.firstName }}!</p>
|
||||
<p class="redirect-message">Redirecting you to the dashboard...</p>
|
||||
</div>
|
||||
|
||||
<!-- MFA Required State -->
|
||||
<div *ngIf="verificationResult?.isValid && verificationResult?.requiresMfa && !isVerifying"
|
||||
class="mfa-required-state">
|
||||
<div class="success-icon">
|
||||
<i class="k-icon k-i-check-circle"></i>
|
||||
</div>
|
||||
<h2>Token Verified!</h2>
|
||||
<p>Welcome back, {{ verificationResult?.user?.firstName }}!</p>
|
||||
<p class="mfa-message">Please complete MFA verification to continue...</p>
|
||||
|
||||
<!-- Debug button to manually trigger MFA dialog -->
|
||||
<div class="debug-actions" style="margin-top: 20px;">
|
||||
<button kendoButton themeColor="primary" (click)="showMfaDialog()" class="action-btn">
|
||||
Show MFA Dialog (Debug)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div *ngIf="errorMessage && !isVerifying" class="error-state">
|
||||
<div class="error-icon">
|
||||
<i class="k-icon k-i-warning"></i>
|
||||
</div>
|
||||
<h2>Link Verification Failed</h2>
|
||||
<p class="error-message">{{ errorMessage }}</p>
|
||||
|
||||
<div class="help-text">
|
||||
<p>If you're having trouble accessing your account:</p>
|
||||
<ul>
|
||||
<li>Make sure you clicked the complete link from your email</li>
|
||||
<li>Check if the link has expired</li>
|
||||
<li>Try requesting a new access link</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button kendoButton themeColor="primary" (click)="requestNewLink()" class="action-btn">
|
||||
Request New Link
|
||||
</button>
|
||||
|
||||
<button kendoButton (click)="goToLogin()" class="action-btn secondary">
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MFA Dialog -->
|
||||
<app-mfa-dialog #mfaDialog (mfaSuccess)="onMfaSuccess($event)" (mfaCancel)="onMfaCancel()">
|
||||
</app-mfa-dialog>
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
.token-verification-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.verification-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.verification-content {
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.verifying-state {
|
||||
.k-loader {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.success-state {
|
||||
.success-icon {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.k-icon {
|
||||
font-size: 48px;
|
||||
color: #28a745;
|
||||
}
|
||||
}
|
||||
|
||||
.redirect-message {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.mfa-required-state {
|
||||
.success-icon {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.k-icon {
|
||||
font-size: 48px;
|
||||
color: #28a745;
|
||||
}
|
||||
}
|
||||
|
||||
.mfa-message {
|
||||
color: #007bff;
|
||||
font-weight: 500;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
.error-icon {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.k-icon {
|
||||
font-size: 48px;
|
||||
color: #dc3545;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 30px;
|
||||
text-align: left;
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.action-btn {
|
||||
min-width: 140px;
|
||||
|
||||
&.secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
|
||||
&:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.verification-card {
|
||||
padding: 30px 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DialogModule } from '@progress/kendo-angular-dialog';
|
||||
import { AuthService, TokenVerificationResult, User } from '../services/auth.service';
|
||||
import { MfaDialogComponent } from '../mfa-dialog/mfa-dialog.component';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-token-verification',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
IndicatorsModule,
|
||||
ButtonsModule,
|
||||
DialogModule,
|
||||
MfaDialogComponent
|
||||
],
|
||||
templateUrl: './token-verification.component.html',
|
||||
styleUrls: ['./token-verification.component.scss']
|
||||
})
|
||||
export class TokenVerificationComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild('mfaDialog') mfaDialog!: MfaDialogComponent;
|
||||
|
||||
isVerifying = true;
|
||||
verificationResult: TokenVerificationResult | null = null;
|
||||
errorMessage = '';
|
||||
tokenUser: User | null = null;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private authService: AuthService
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.subscribe(params => {
|
||||
const token = params['token'];
|
||||
if (token) {
|
||||
this.verifyToken(token);
|
||||
} else {
|
||||
this.handleError('No token provided in the link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
console.log('AfterViewInit - MFA dialog reference:', this.mfaDialog);
|
||||
}
|
||||
|
||||
private verifyToken(token: string): void {
|
||||
this.isVerifying = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
// Validate token format first
|
||||
if (!this.isValidJwtFormat(token)) {
|
||||
this.handleError('Invalid token format. Please check your email link.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.authService.verifySecretLinkToken(token).subscribe({
|
||||
next: (result: TokenVerificationResult) => {
|
||||
this.isVerifying = false;
|
||||
this.verificationResult = result;
|
||||
|
||||
if (result.isValid && result.user) {
|
||||
// Token is valid, store user data and show MFA dialog
|
||||
this.tokenUser = result.user;
|
||||
this.verificationResult = result;
|
||||
|
||||
console.log('Token verification result:', result);
|
||||
console.log('Requires MFA:', result.requiresMfa);
|
||||
|
||||
if (result.requiresMfa) {
|
||||
// Show MFA dialog for token-based authentication
|
||||
console.log('Showing MFA dialog...');
|
||||
this.authService.verifyMfaForToken('', result.user).pipe(
|
||||
take(1)
|
||||
).subscribe(result => {
|
||||
this.showMfaDialog();
|
||||
});
|
||||
|
||||
} else {
|
||||
// If MFA is not required, proceed directly
|
||||
console.log('MFA not required, proceeding directly...');
|
||||
this.authService.setCurrentUser(result.user);
|
||||
setTimeout(() => {
|
||||
this.redirectToDashboard();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
this.handleError(result.message || 'Invalid or expired link. Please request a new one.');
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.isVerifying = false;
|
||||
this.handleError('An error occurred while verifying the link. Please try again.');
|
||||
console.error('Token verification error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isValidJwtFormat(token: string): boolean {
|
||||
// Basic JWT format validation (3 parts separated by dots)
|
||||
const parts = token.split('.');
|
||||
return parts.length === 3 && parts.every(part => part.length > 0);
|
||||
}
|
||||
|
||||
private handleError(message: string): void {
|
||||
this.isVerifying = false;
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
private redirectToDashboard(): void {
|
||||
const redirectUrl = this.authService.getRedirectUrl();
|
||||
this.router.navigate([redirectUrl || '/dashboard']);
|
||||
}
|
||||
|
||||
goToLogin(): void {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
requestNewLink(): void {
|
||||
// This could redirect to a "request new link" page or contact form
|
||||
// For now, redirect to login
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
showMfaDialog(): void {
|
||||
console.log('showMfaDialog called, tokenUser:', this.tokenUser);
|
||||
|
||||
if (this.tokenUser) {
|
||||
// Set the user data for MFA dialog first
|
||||
const loginData = {
|
||||
email: this.tokenUser.email,
|
||||
password: '', // Not needed for token-based auth
|
||||
tokenUser: this.tokenUser
|
||||
};
|
||||
|
||||
// Use multiple approaches to ensure the dialog shows
|
||||
const tryShowDialog = (attempt: number = 1) => {
|
||||
console.log(`Attempt ${attempt} to show MFA dialog`);
|
||||
|
||||
if (this.mfaDialog) {
|
||||
console.log('MFA dialog found, setting loginData and showing...');
|
||||
(this.mfaDialog as any).loginData = loginData;
|
||||
this.mfaDialog.show();
|
||||
} else if (attempt < 5) {
|
||||
// Try again after a short delay
|
||||
setTimeout(() => tryShowDialog(attempt + 1), 100);
|
||||
} else {
|
||||
console.error('MFA dialog not found after multiple attempts');
|
||||
}
|
||||
};
|
||||
|
||||
// Start trying to show the dialog
|
||||
tryShowDialog();
|
||||
} else {
|
||||
console.error('No token user available for MFA dialog');
|
||||
}
|
||||
}
|
||||
|
||||
onMfaSuccess(userData: any): void {
|
||||
this.authService.setCurrentUser(userData);
|
||||
this.redirectToDashboard();
|
||||
}
|
||||
|
||||
onMfaCancel(): void {
|
||||
// Reset to initial state
|
||||
this.isVerifying = true;
|
||||
this.verificationResult = null;
|
||||
this.tokenUser = null;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
|
||||
export class ArrayUtils {
|
||||
|
||||
public static Equals(array1: Array<any>, array2: Array<any>): boolean {
|
||||
// if the other array is a falsy value, return
|
||||
if (!array2)
|
||||
return false;
|
||||
|
||||
// compare lengths - can save a lot of time
|
||||
if (array1.length != array2.length)
|
||||
return false;
|
||||
|
||||
for (var i = 0, l = array1.length; i < l; i++) {
|
||||
// Check if we have nested arrays
|
||||
if (array1[i] instanceof Array && array2[i] instanceof Array) {
|
||||
// recurse into the nested arrays
|
||||
if (!ArrayUtils.Equals(array1[i], array2[i]))
|
||||
return false;
|
||||
}
|
||||
else if (array1[i] != array2[i]) {
|
||||
// Warning - two different object instances will never be equal: {x:20} != {x:20}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// public static ToDropDownOptions(array: any[], keyProperty: string | number, valueProperty: string | number): DropDownOption[] {
|
||||
// var result = [];
|
||||
// for (let i = 0; i < array.length; i++) {
|
||||
// const element = array[i];
|
||||
// result.push(new DropDownOption(element[keyProperty], element[valueProperty]));
|
||||
// }
|
||||
// return result;
|
||||
// }
|
||||
|
||||
|
||||
public static GroupBy<T = any>(arr: Array<T>, groupKeyPrepareFunction: (obj: T) => any | any[]) {
|
||||
|
||||
let groups = arr.reduce(function (groupModel, obj) {
|
||||
|
||||
let keys = groupKeyPrepareFunction(obj);
|
||||
|
||||
const addToGroup = (key: any, obj: T) => {
|
||||
let group = groupModel.find(g => g.key == key);
|
||||
if (group == null) {
|
||||
group = new GroupModel(key);
|
||||
groupModel.push(group);
|
||||
}
|
||||
group.values.push(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(keys)) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
addToGroup(key, obj);
|
||||
}
|
||||
} else {
|
||||
addToGroup(keys, obj);
|
||||
}
|
||||
return groupModel;
|
||||
}, [] as GroupModel<T>[]);
|
||||
return groups;
|
||||
}
|
||||
public static RemoveDuplicate<T>(objArray: T[], duplicateDetection: (a: T, b: T) => boolean) {
|
||||
return objArray.filter((value, index, self) =>
|
||||
index === self.findIndex((t) => duplicateDetection(t, value))
|
||||
);
|
||||
}
|
||||
|
||||
public static insertAt = (arr: any[], index: number, newItems: any) => [
|
||||
// part of the array before the specified index
|
||||
...arr.slice(0, index),
|
||||
// inserted items
|
||||
newItems,
|
||||
// part of the array after the specified index
|
||||
...arr.slice(index)
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
export class GroupModel<T = any> {
|
||||
constructor(key: any) {
|
||||
this.key = key;
|
||||
this.values = [];
|
||||
}
|
||||
|
||||
key: any;
|
||||
values: T[];
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
|
||||
export class DateUtils {
|
||||
|
||||
|
||||
// public static getIntervalDays(from: Date, to: Date, daysOfYear: DaysOfYear = DaysOfYear.ThirtyDaysPerMonth, countEndDate: boolean = false): number {
|
||||
// let isNegative = false;
|
||||
// if (from > to) {
|
||||
// isNegative = true;
|
||||
// let temp = new Date(to);
|
||||
// to = from;
|
||||
// from = temp;
|
||||
// }
|
||||
|
||||
// var days = 0;
|
||||
// if (from && to) {
|
||||
// //Get date without time
|
||||
// from = new Date(from.getFullYear(), from.getMonth(), from.getDate());
|
||||
// to = new Date(to.getFullYear(), to.getMonth(), to.getDate());
|
||||
// var differenceTime = to.getTime() - from.getTime();
|
||||
// if (differenceTime > 0) {
|
||||
// if (daysOfYear == DaysOfYear.ThirtyDaysPerMonth) {
|
||||
// var fromYear = from.getFullYear();
|
||||
// var toYear = to.getFullYear();
|
||||
// var fromMonth = from.getMonth() + 1;
|
||||
// var toMonth = to.getMonth() + 1;
|
||||
|
||||
// var fromDays = from.getDate() > 30 ? 30 : from.getDate();
|
||||
// var toDays = to.getDate();
|
||||
|
||||
// days += 30 - fromDays;
|
||||
// days += toDays;
|
||||
// //calculate full 12 months years
|
||||
// if (toYear > (fromYear + 1)) {
|
||||
// days += (toYear - fromYear - 1) * 12 * 30;
|
||||
// }
|
||||
|
||||
// //if it's two different years, calculate the interval months
|
||||
// if (toYear > fromYear) {
|
||||
// days += (12 - fromMonth) * 30;
|
||||
// days += (toMonth - 1) * 30;
|
||||
// }
|
||||
// else {
|
||||
// //same year
|
||||
// days += (toMonth - fromMonth - 1) * 30
|
||||
// }
|
||||
// }
|
||||
// else {
|
||||
// // To calculate the no. of days between two dates
|
||||
// days = Math.round(differenceTime / (1000 * 3600 * 24));
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return (days + (countEndDate ? 1 : 0)) * (isNegative ? -1 : 1);
|
||||
// }
|
||||
|
||||
public static addDays(date: Date, days: number): Date {
|
||||
if (date) {
|
||||
var result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
public static format(date: Date, format: string = 'MM/dd/yyyy hh:mm:ss', nullFormat: string = ''): string {
|
||||
if (date) {
|
||||
var z = {
|
||||
M: date.getMonth() + 1,
|
||||
d: date.getDate(),
|
||||
H: date.getHours(),
|
||||
h: (date.getHours() == 0 ? date.getHours() + 12 : date.getHours() > 12 ? date.getHours() - 12 : date.getHours()),
|
||||
m: date.getMinutes(),
|
||||
s: date.getSeconds(),
|
||||
a: (date.getHours() > 11 ? 'PM' : 'AM')
|
||||
};
|
||||
format = format.replace(/(M+|d+|H+|h+|m+|s+|a+)/g, function (v) {
|
||||
return ((v.length > 1 ? "0" : "") + z[v.slice(-1) as keyof typeof z]).slice(-2);
|
||||
});
|
||||
|
||||
return format.replace(/(y+)/g, function (v) {
|
||||
return date.getFullYear().toString().slice(-v.length)
|
||||
});
|
||||
}
|
||||
return nullFormat;
|
||||
}
|
||||
public static isValidDate(d: Date): boolean {
|
||||
return d instanceof Date && d.getTime() == d.getTime();
|
||||
}
|
||||
public static parse(value: string | Date | null | undefined, changeToLocalTime = false): Date | null {
|
||||
if (value) {
|
||||
if (typeof value === 'string' && value.includes('-')) {
|
||||
value = this.parseLocalDate(value);
|
||||
return value;
|
||||
}
|
||||
value = new Date(value);
|
||||
if (changeToLocalTime) {
|
||||
//todo: change to local time from UTC
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return value
|
||||
}
|
||||
public static parseLocalDate(localDate: string): Date {
|
||||
const [year, month, day] = localDate.split('-').map(Number);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
public static toLocalDate(date: Date): string {
|
||||
return this.format(date, 'yyyy-MM-dd');
|
||||
}
|
||||
public static getToday(endOfDay: boolean = false): Date {
|
||||
let value = new Date();
|
||||
if (!endOfDay) {
|
||||
value.setHours(0, 0, 0, 0);
|
||||
}
|
||||
else {
|
||||
value.setHours(23, 59, 59, 999);
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
public static getBeginOfDate(value: Date): Date {
|
||||
if (value) {
|
||||
value = new Date(value);
|
||||
value.setHours(0, 0, 0, 0);
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
public static getEndOfDate(value: Date): Date {
|
||||
if (value) {
|
||||
value = new Date(value);
|
||||
value.setHours(23, 59, 59, 999);
|
||||
}
|
||||
return value
|
||||
}
|
||||
public static getEndOfMonth(value: Date): Date {
|
||||
if (value) {
|
||||
return new Date(value.getFullYear(), value.getMonth() + 1, 0)
|
||||
}
|
||||
return value;
|
||||
}
|
||||
public static getFirstDayOfCurrentMonth = (): Date => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
};
|
||||
|
||||
public static getLastDayOfCurrentMonth = (): Date => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
};
|
||||
|
||||
public static isSameDate(date: Date, comparison: Date): boolean {
|
||||
if (!date || !comparison) return (!date && !comparison);
|
||||
date = this.parse(date, false) as Date;
|
||||
comparison = this.parse(comparison, false) as Date;
|
||||
return date.getFullYear() == comparison.getFullYear() && date.getMonth() == comparison.getMonth() && date.getDate() == comparison.getDate();
|
||||
}
|
||||
|
||||
|
||||
public static getTimeStamp() {
|
||||
var now = new Date();
|
||||
return ((now.getMonth() + 1) + '/' + (now.getDate()) + '/' + now.getFullYear() + " " + now.getHours() + ':'
|
||||
+ ((now.getMinutes() < 10) ? ("0" + now.getMinutes()) : (now.getMinutes())) + ':' + ((now.getSeconds() < 10) ? ("0" + now
|
||||
.getSeconds()) : (now.getSeconds())));
|
||||
}
|
||||
|
||||
|
||||
public static getDatesBetween(startDate: Date, endDate: Date): Date[] {
|
||||
startDate = this.getBeginOfDate(startDate);
|
||||
endDate = this.getBeginOfDate(endDate);
|
||||
let result = [startDate];
|
||||
if (startDate < endDate) {
|
||||
let tempDate = new Date(startDate);
|
||||
while (tempDate < endDate) {
|
||||
tempDate = this.addDays(tempDate, 1);
|
||||
result.push(tempDate);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored time value in milliseconds since midnight, January 1, 1970 UTC., return 0 if date is null
|
||||
*/
|
||||
public static getTime(date?: Date): number {
|
||||
return date != null ? date.getTime() : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export class EnumUtils {
|
||||
|
||||
public static hasFlag(obj: number, enumValue: number): boolean {
|
||||
if (obj) {
|
||||
if (obj & enumValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static GetAllEnumValue(enumType: any): number[] {
|
||||
return Object
|
||||
.keys(enumType)
|
||||
.filter((v) => !isNaN(Number(v)))
|
||||
.map(v => Number.parseInt(v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export class FileUtils {
|
||||
public static formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} Bytes`;
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
} else {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
public static getFileName(fileFullPath: string): string | null {
|
||||
const lastSlashIndex = fileFullPath.lastIndexOf('\\');
|
||||
if (lastSlashIndex === -1 || lastSlashIndex === 0 || lastSlashIndex === fileFullPath.length - 1) {
|
||||
return fileFullPath; // No folder found
|
||||
}
|
||||
return fileFullPath.slice(lastSlashIndex + 1);
|
||||
}
|
||||
public static getFileExt(filename: string): string | null {
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1 || lastDotIndex === 0 || lastDotIndex === filename.length - 1) {
|
||||
return null; // No extension found
|
||||
}
|
||||
return filename.slice(lastDotIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export class HtmlElementUtils {
|
||||
|
||||
|
||||
public static findChildByClassName<T = Element>(children: HTMLCollection, className: string): T {
|
||||
|
||||
let childrenNodeArr = Array.from(children);
|
||||
for (let i = 0; i < childrenNodeArr.length; i++) {
|
||||
const element = childrenNodeArr[i];
|
||||
if (element.classList.contains(className)) {
|
||||
return element as T;
|
||||
} else if (element.children) {
|
||||
let child = this.findChildByClassName(element.children, className)
|
||||
if (child) return child as T;
|
||||
}
|
||||
}
|
||||
return null as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
export class LinqUtils {
|
||||
public static GroupBy(xs: any[], key: any) {
|
||||
return xs.reduce(function (rv, x) {
|
||||
(rv[x[key]] = rv[x[key]] || []).push(x);
|
||||
return rv;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { formatCurrency } from "@angular/common";
|
||||
|
||||
const PPI = 96;
|
||||
export class NumberUtils {
|
||||
public static Ordinal(value: number): string {
|
||||
let suffix = '';
|
||||
const last = value % 10;
|
||||
const specialLast = value % 100;
|
||||
if (!value || value < 1) {
|
||||
return value.toString();
|
||||
}
|
||||
if (last === 1 && specialLast !== 11) {
|
||||
suffix = 'st';
|
||||
} else if (last === 2 && specialLast !== 12) {
|
||||
suffix = 'nd';
|
||||
} else if (last === 3 && specialLast !== 13) {
|
||||
suffix = 'rd';
|
||||
} else {
|
||||
suffix = 'th';
|
||||
}
|
||||
return value + suffix;
|
||||
}
|
||||
|
||||
public static Clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
public static SortFunction(a: number, b: number): number {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
public static Sum(array: number[]): number {
|
||||
|
||||
return array.reduce((a, b) => (isNaN(a) ? 0 : a) + (isNaN(b) ? 0 : b), 0);
|
||||
}
|
||||
|
||||
public static FormatCurrency(v: number, zeroExpression: string = '0'): string {
|
||||
|
||||
return ['', 0, null, undefined, NaN].includes(v) ? zeroExpression : formatCurrency(v, "en", "", "", `0.2`);
|
||||
}
|
||||
public static Round(num: number, precision: number) {
|
||||
const factor = 10 ** precision;
|
||||
return Math.round(num * factor) / factor;
|
||||
}
|
||||
|
||||
public static RoundCurrency(num: number): number {
|
||||
return this.Round(num, 2);
|
||||
}
|
||||
|
||||
public static VersionDiff(v1: string, v2: string) {
|
||||
let versionDefine = [
|
||||
{ index: 0, name: 'major' },
|
||||
{ index: 1, name: 'minor' },
|
||||
{ index: 2, name: 'patch' },
|
||||
{ index: 3, name: 'build' }
|
||||
]
|
||||
if (v1 && v2) {
|
||||
let v1Versions = v1.split('.');
|
||||
let v2Versions = v2.split('.');
|
||||
|
||||
for (let i = 0; i < v1Versions.length; i++) {
|
||||
if (v2Versions.length == i) return versionDefine[i];
|
||||
if (v1Versions[i] != v2Versions[i]) return versionDefine[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Average(numArr: number[]) {
|
||||
return numArr.reduce((a, b) => a + b) / numArr.length;
|
||||
}
|
||||
|
||||
public static PixelToInch(pixel: number) {
|
||||
return this.Round(pixel / PPI, 2);
|
||||
}
|
||||
public static InchToPixel(inch: number) {
|
||||
return Math.round(inch * PPI);
|
||||
}
|
||||
|
||||
public static Mid(a: number, b: number) {
|
||||
return a + Math.floor((b - a) / 2);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
const dateAndTimeRegex = new RegExp(/^(?<Date>\d{4}-\d{2}-\d{2})T(?<HourMin>\d{2}:\d{2}):((?<SecondAndMillisecond>\d{2}\.\d{0,6})|(?<Second>\d{2}))$/);
|
||||
|
||||
export class ObjectUtils {
|
||||
|
||||
private static ReviveDateTime(key: any, value: any): any {
|
||||
if (typeof value === 'string') {
|
||||
if (dateAndTimeRegex.test(value)) {
|
||||
let newDate = new Date(value);
|
||||
return newDate;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static HasAnyData(obj: any, excludes: string[] = []) {
|
||||
var hasData = false;
|
||||
|
||||
for (const p in obj) {
|
||||
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(obj, p)) {
|
||||
const element = obj[p];
|
||||
if (element) {
|
||||
if (typeof element !== 'object' || this.HasAnyData(element, excludes)) {
|
||||
hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasData;
|
||||
}
|
||||
public static Clone<T = any>(obj: T, avoidCirculateRef = false): T {
|
||||
if (avoidCirculateRef) {
|
||||
return JSON.parse(this.stringify(obj), ObjectUtils.ReviveDateTime);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(obj), ObjectUtils.ReviveDateTime);
|
||||
}
|
||||
|
||||
public static CopyValue(source: any, destination: any, excludes: string[] = ["id"], overwriting: boolean = true) {
|
||||
|
||||
for (const p in source) {
|
||||
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(source, p)) {
|
||||
const element = source[p];
|
||||
|
||||
if (element && Array.isArray(element)) {
|
||||
if ([null, undefined].includes(destination[p])) {
|
||||
destination[p] = [];
|
||||
for (let i = 0; i < element.length; i++) {
|
||||
const cloneItem = {};
|
||||
this.CopyValue(element[i], cloneItem, excludes, true);
|
||||
destination[p].push(cloneItem);
|
||||
}
|
||||
} else if (Array.isArray(destination[p])) {
|
||||
|
||||
for (let i = 0; i < element.length; i++) {
|
||||
const item = element[i];
|
||||
let destLength = destination[p].length;
|
||||
if (i >= destLength) {
|
||||
destination[p].push(this.Clone(source[p][i]));
|
||||
}
|
||||
this.CopyValue(item, destination[p][i], excludes, overwriting);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (element && typeof element.getMonth === 'function') {
|
||||
//For angular will treat Date as object
|
||||
try {
|
||||
destination[p] = element;
|
||||
} catch (error) {
|
||||
console.log(`can\'t copy ${p}`, error);
|
||||
}
|
||||
}
|
||||
else if (element && typeof element == 'object') {
|
||||
let objectOverwriting = overwriting;
|
||||
if ([null, undefined].includes(destination[p])) {
|
||||
destination[p] = {};
|
||||
objectOverwriting = true;
|
||||
}
|
||||
try {
|
||||
this.CopyValue(element, destination[p], excludes, objectOverwriting);
|
||||
} catch (error) {
|
||||
console.log(`can\'t copy ${p}`, error);
|
||||
}
|
||||
} else if (overwriting || [null, '', undefined].includes(destination[p])) {
|
||||
try {
|
||||
destination[p] = element;
|
||||
} catch (error) {
|
||||
console.log(`can\'t copy ${p}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public static ClearPkAndFk(source: any, excludes: string[] = [], clearExtra: string[] = []) {
|
||||
|
||||
for (const p in source) {
|
||||
const element = source[p];
|
||||
|
||||
if (element) {
|
||||
if (clearExtra.includes(p)) {
|
||||
source[p] = null;
|
||||
} else if (typeof element == 'object') {
|
||||
this.ClearPkAndFk(element, excludes, clearExtra);
|
||||
} else if (
|
||||
(typeof element == 'string' && (p == 'id' || p.indexOf('Id') > -1) &&
|
||||
false == excludes.includes(p))
|
||||
) {
|
||||
source[p] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public static ClearValue(source: any, excludes: string[] = ["id"]) {
|
||||
for (const p in source) {
|
||||
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(source, p)) {
|
||||
const element = source[p];
|
||||
|
||||
if (element) {
|
||||
if (typeof element == 'object') {
|
||||
this.ClearValue(element, excludes);
|
||||
} else if (typeof element == 'number') {
|
||||
source[p] = 0;
|
||||
} else {
|
||||
source[p] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* return `true` if the comparison has any different value with source.
|
||||
*/
|
||||
public static CompareDiffValue(source: any, comparison: any, excludes: string[] = ["id"]) {
|
||||
let isDifferent = false;
|
||||
for (const p in source) {
|
||||
if (false == excludes.includes(p) &&
|
||||
Object.prototype.hasOwnProperty.call(source, p)) {
|
||||
const element = source[p];
|
||||
if (element && Array.isArray(element)) {
|
||||
if (Array.isArray(comparison[p])) {
|
||||
for (let i = 0; i < element.length; i++) {
|
||||
const item = element[i];
|
||||
let destLength = comparison[p].length;
|
||||
if (i >= destLength) {
|
||||
return true;
|
||||
}
|
||||
isDifferent = this.CompareDiffValue(item, comparison[p][i], excludes);
|
||||
if (isDifferent) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (element && typeof element.getMonth === 'function') {
|
||||
//For angular will treat Date as object
|
||||
//TODO:Compare Date
|
||||
//comparison[p] = element;
|
||||
}
|
||||
else if (element && typeof element == 'object') {
|
||||
isDifferent = this.CompareDiffValue(element, comparison[p], excludes);
|
||||
|
||||
} else {
|
||||
isDifferent = comparison[p] != element;
|
||||
}
|
||||
|
||||
if (isDifferent) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* return `true` if the comparison has any different value with source.
|
||||
*/
|
||||
public static CompareDiffArrayContent(source: any[], comparison: any[], excludes: string[] = ["id"]) {
|
||||
let isDifferent = false;
|
||||
if (source && comparison) {
|
||||
if (source.length == comparison.length) {
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const sourceItem = source[i];
|
||||
const comparisonItem = comparison[i];
|
||||
|
||||
isDifferent = this.CompareDiffValue(sourceItem, comparisonItem);
|
||||
if (isDifferent) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static isNullOrUndefined(obj: any) {
|
||||
return [null, undefined].includes(obj);
|
||||
}
|
||||
|
||||
public static isObservable<T>(template: T | Observable<T>): template is Observable<T> {
|
||||
return (template as Observable<T>).subscribe !== undefined;
|
||||
}
|
||||
public static stringify(obj: any): string {
|
||||
let cache: any[] = [];
|
||||
let str = JSON.stringify(obj, function (key, value) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (cache.indexOf(value) !== -1) {
|
||||
// Circular reference found, discard key
|
||||
return;
|
||||
}
|
||||
// Store value in our collection
|
||||
cache.push(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
cache = []; // reset the cache
|
||||
return str;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { AddressInfo } from "../models";
|
||||
|
||||
export class StringUtils {
|
||||
static compare(aval: string, bval: string) {
|
||||
return aval ? aval.localeCompare(bval) : -1;
|
||||
}
|
||||
|
||||
// Sorting function for SemVer
|
||||
// Returns 1 if a is greater, -1 if b is greater, 0 if equal
|
||||
public static compareSemVer(a: string, b: string): number {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var a_components = a.split(".");
|
||||
var b_components = b.split(".");
|
||||
|
||||
var len = Math.min(a_components.length, b_components.length);
|
||||
|
||||
// loop while the components are equal
|
||||
for (var i = 0; i < len; i++) {
|
||||
// A bigger than B
|
||||
if (parseInt(a_components[i]) > parseInt(b_components[i])) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// B bigger than A
|
||||
if (parseInt(a_components[i]) < parseInt(b_components[i])) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// If one's a prefix of the other, the longer one is greater.
|
||||
if (a_components.length > b_components.length) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a_components.length < b_components.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Otherwise they are the same.
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static isNullOrWhitespace(input: string): boolean {
|
||||
return !input || !input.trim();
|
||||
}
|
||||
|
||||
public static getTrimmedValue(input: string, emptyFormat: string = ''): string {
|
||||
if (input) {
|
||||
input = input.trim();
|
||||
}
|
||||
if (input) return input;
|
||||
return emptyFormat;
|
||||
}
|
||||
|
||||
public static removeNewLines(input: string): string {
|
||||
if (input) {
|
||||
input = input.replace(/[\r\n]+/g, '');
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
public static firstIsVowel(s: string): boolean {
|
||||
if (s) {
|
||||
return ['a', 'e', 'i', 'o', 'u'].indexOf(s[0].toLowerCase()) !== -1
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static makeCommaSeparatedString(arr: string[], useOxfordComma: boolean,
|
||||
conjunctionWord: string = "and") {
|
||||
arr = arr.filter(s => s);
|
||||
const listStart = arr.slice(0, -1).join(', ');
|
||||
const listEnd = arr.slice(-1);
|
||||
const conjunction =
|
||||
arr.length <= 1
|
||||
? ""
|
||||
: useOxfordComma && arr.length > 2
|
||||
? `, ${conjunctionWord} `
|
||||
: ` ${conjunctionWord} `;
|
||||
|
||||
return [listStart, listEnd].join(conjunction);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static getFormattedTerm(input: string, emptyFormatter: string = 'Empty'): string {
|
||||
if (false == this.isNullOrWhitespace(input)) {
|
||||
return input.trim();
|
||||
}
|
||||
return emptyFormatter;
|
||||
}
|
||||
public static camelToTitle(camelCase: string): string {
|
||||
// no side-effects
|
||||
return camelCase
|
||||
// inject space before the upper case letters
|
||||
.replace(/([A-Z])/g, function (match) {
|
||||
return " " + match;
|
||||
})
|
||||
// replace first char with upper case
|
||||
.replace(/^./, function (match) {
|
||||
return match.toUpperCase();
|
||||
}).trim();
|
||||
}
|
||||
public static getCapitalLetters(str: string) {
|
||||
return str.replace(/[^A-Z]+/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* status could be `info` , `primary` , `danger` , `warning` , `success`
|
||||
*/
|
||||
public static getHtmlBadge(text: string, badgeStatus: string, mergingClass = 'mr-1') {
|
||||
return `<label class="badge badge-${badgeStatus} ${mergingClass} my-0 py-1">${text}</label>`;
|
||||
|
||||
}
|
||||
|
||||
public static getHtmlInnerText(htmlText: string) {
|
||||
return htmlText ? htmlText.replace(/<[^>]+>/g, '') : '';
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse city sate zip string like `Monrovia, CA 91016` to AddressInfo, if failed will return `null` instead.
|
||||
*/
|
||||
public static tryParseCityStateZip(cityStateZip: string): AddressInfo {
|
||||
|
||||
let addressInfo = {} as AddressInfo;
|
||||
var regex = /([\w\s]*),\s*([A-Z]{2})\s*(\d*-?\d*)/g;
|
||||
var match = regex.exec(cityStateZip);
|
||||
if (match) {
|
||||
addressInfo = {
|
||||
city: match[1],
|
||||
state: match[2],
|
||||
zip: match[3]
|
||||
} as AddressInfo;
|
||||
return addressInfo;
|
||||
} else {
|
||||
return {} as AddressInfo;
|
||||
}
|
||||
}
|
||||
|
||||
public static getCityStateZipString(addressInfo: AddressInfo): string {
|
||||
let result = '';
|
||||
if (false == this.isNullOrWhitespace(addressInfo.city)) {
|
||||
result += `${addressInfo.city}, `;
|
||||
}
|
||||
if (false == this.isNullOrWhitespace(addressInfo.state)) {
|
||||
result += `${addressInfo.state} `;
|
||||
}
|
||||
if (false == this.isNullOrWhitespace(addressInfo.zip)) {
|
||||
result += addressInfo.zip;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static getFullAddressString(addressInfo: AddressInfo): string {
|
||||
|
||||
if (addressInfo && StringUtils.isNullOrWhitespace(addressInfo.address)) {
|
||||
return 'No Subject Property Entered';
|
||||
}
|
||||
else {
|
||||
return `${addressInfo.address}, ${this.getCityStateZipString(addressInfo)}`
|
||||
}
|
||||
}
|
||||
public static toUpperString(prop: any) {
|
||||
if (prop) {
|
||||
if (typeof prop === 'string') return prop.toUpperCase();
|
||||
return prop.toString().toUpperCase();
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
public static toLowerString(prop: any) {
|
||||
if (prop) {
|
||||
if (typeof prop === 'string') return prop.toLowerCase();
|
||||
return prop.toString().toLowerCase();
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
public static truncateString(str: string, maxLength: number) {
|
||||
if (str && str.length > maxLength) {
|
||||
return str.slice(0, maxLength);
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
public static insertAt(str: string, insertValue: string, index: number) {
|
||||
return [str.slice(0, index), insertValue, str.slice(index)].join('');
|
||||
}
|
||||
public static replaceAll(str: string, find: string, replace: string) {
|
||||
return str.replace(new RegExp(find, 'g'), replace);
|
||||
}
|
||||
public static safeLocaleCompare(a: string, b: string, sortDirection: string = 'asc') {
|
||||
if (sortDirection == 'asc' && a)
|
||||
return b ? a.localeCompare(b) : -1;
|
||||
else if (b)
|
||||
return a ? b.localeCompare(a) : 1;
|
||||
else return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
export class TextareaUtils {
|
||||
|
||||
public static autoExpand(field: HTMLTextAreaElement) {
|
||||
//
|
||||
if (field) {
|
||||
// Reset field height
|
||||
field.style.height = '0px';
|
||||
const computed = window.getComputedStyle(field);
|
||||
// Calculate the height
|
||||
var height = 0
|
||||
+ parseInt(computed.getPropertyValue('border-top-width'), 10)
|
||||
+ field.scrollHeight
|
||||
+ parseInt(computed.getPropertyValue('border-bottom-width'), 10);
|
||||
|
||||
field.style.height = height + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
public static isEditingKeyPress(e: KeyboardEvent) {
|
||||
|
||||
if ([46, 8, 9, 27, 13, 190].indexOf(e.keyCode) !== -1 ||
|
||||
// Allow: Ctrl+A
|
||||
(e.keyCode === 65 && (e.ctrlKey || e.metaKey)) ||
|
||||
// Allow: Ctrl+C
|
||||
(e.keyCode === 67 && (e.ctrlKey || e.metaKey)) ||
|
||||
// Allow: Ctrl+V
|
||||
(e.keyCode === 86 && (e.ctrlKey || e.metaKey)) ||
|
||||
// Allow: Ctrl+X
|
||||
(e.keyCode === 88 && (e.ctrlKey || e.metaKey)) ||
|
||||
// Allow: page up, page down, home, end, left, right
|
||||
(e.keyCode >= 33 && e.keyCode <= 39)) {
|
||||
// let it happen, don't do anything
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof e.which == "undefined") {
|
||||
// This is IE, which only fires keypress events for printable keys
|
||||
return true;
|
||||
} else if (e.ctrlKey && e.key.toUpperCase() == 'V') {
|
||||
return true;
|
||||
}
|
||||
else if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code != 'Tab' && e.key != 'Control') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export class TimerUtils {
|
||||
|
||||
// private static debounceTimers: DebounceTimer[];
|
||||
|
||||
// private static addDebounceTimer(key: string, debounceTime: number, callback: Function) {
|
||||
// if (!this.debounceTimers) {
|
||||
// this.debounceTimers = [];
|
||||
// }
|
||||
// let timerProfile = this.debounceTimers.find(t => t.key == key);
|
||||
// if (timerProfile) {
|
||||
// clearTimeout(timerProfile.timer);
|
||||
// } else {
|
||||
// timerProfile = new DebounceTimer(){
|
||||
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
export class DebounceTimer {
|
||||
|
||||
constructor(
|
||||
debounceTime: number,
|
||||
callback: Function
|
||||
) {
|
||||
//this.key = key
|
||||
this.debounceTime = debounceTime;
|
||||
this.callback = callback;
|
||||
//this.resetTimer();
|
||||
}
|
||||
|
||||
debounceTime: number;
|
||||
timer: any;
|
||||
callback: Function;
|
||||
resetTimer() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.callback();
|
||||
this.timer = null;
|
||||
}, this.debounceTime);
|
||||
}
|
||||
clearOut() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
const GUID_EMPTY = '00000000-0000-0000-0000-000000000000';
|
||||
export class UuidUtils {
|
||||
|
||||
public static generate() {
|
||||
var d = new Date().getTime();//Timestamp
|
||||
var d2 = (performance && performance.now && (performance.now() * 1000)) || 0;//Time in microseconds since page-load or 0 if unsupported
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = Math.random() * 16;//random number between 0 and 16
|
||||
if (d > 0) {//Use timestamp until depleted
|
||||
r = (d + r) % 16 | 0;
|
||||
d = Math.floor(d / 16);
|
||||
} else {//Use microseconds since page-load if supported
|
||||
r = (d2 + r) % 16 | 0;
|
||||
d2 = Math.floor(d2 / 16);
|
||||
}
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
public static stringToUuid = (str: string) => {
|
||||
str = str.replace('-', '');
|
||||
let index = -1;
|
||||
return 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/[x]/g, function (c, p) {
|
||||
index++;
|
||||
return str[index];
|
||||
});
|
||||
}
|
||||
public static empty() {
|
||||
return GUID_EMPTY;
|
||||
}
|
||||
|
||||
public static isNullOrEmpty(uuid: string) {
|
||||
return !uuid || [GUID_EMPTY, undefined, null].includes(uuid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user