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
+16
View File
@@ -0,0 +1,16 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor]))
]
};
+2
View File
@@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<div kendoDialogContainer></div>
+27
View File
@@ -0,0 +1,27 @@
import { Routes } from '@angular/router';
import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashboard.component';
import { LoginPage } from './features/login-page/login-page';
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
import { AuthGuard } from './core/guards/auth.guard';
export const routes: Routes = [
// Public routes
{ path: 'login', component: LoginPage },
// Keep the startup surface intentionally small: login + guarded mock dashboard.
{
path: 'user-portal',
component: UserPortalComponent,
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent }
]
},
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'dashboard', redirectTo: 'user-portal/dashboard' },
// Catch all route - redirect to login
{ path: '**', redirectTo: 'login' }
];
+53
View File
@@ -0,0 +1,53 @@
/* Global layout styles */
/* Ensure AppBar sections are in one row */
kendo-appbar {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
width: 100% !important;
}
kendo-appbar-section {
display: flex !important;
align-items: center !important;
}
/* Make sure the drawer container takes full height */
kendo-drawer-container {
min-height: calc(100vh - 46px);
}
/* Global mobile optimizations */
@media (max-width: 767px) {
/* Improve touch targets for mobile */
button[kendoButton] {
min-height: 44px;
min-width: 44px;
}
/* Make drawer content full width on mobile */
kendo-drawer-content {
width: 100%;
}
/* Prevent horizontal scroll on mobile */
body {
overflow-x: hidden;
}
}
/* iOS specific fixes */
@supports (-webkit-touch-callout: none) {
/* Prevent iOS zoom on input focus */
input,
select,
textarea {
font-size: 16px !important;
}
/* Smooth scrolling for iOS - legacy support for older devices */
kendo-drawer-container {
overflow-y: auto;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Component, ViewEncapsulation, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { DialogModule } from '@progress/kendo-angular-dialog';
import { AuthService } from './shared/services/auth.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
DialogModule
],
templateUrl: './app.html',
styleUrls: ['./app.scss', '../styles.scss'],
encapsulation: ViewEncapsulation.None
})
export class App implements OnInit {
title = 'RBJ Identity';
constructor(private authService: AuthService) { }
ngOnInit(): void {
// Initialize authentication state from localStorage
this.authService.initializeAuth();
}
}
+120
View File
@@ -0,0 +1,120 @@
# Authentication Guard System
This implementation provides a complete authentication system that automatically detects if a user is logged in and redirects to the login page if not.
## Components
### AuthGuard (`auth.guard.ts`)
- **Purpose**: Protects routes that require authentication
- **Functionality**:
- Checks if user is authenticated using `AuthService.isAuthenticated()`
- Stores attempted URL for redirect after login
- Redirects to `/login` if not authenticated
- Allows access if authenticated
### LoginPage (`login-page.ts`)
- **Purpose**: Full-page login interface for routing
- **Features**:
- Beautiful landing page with company branding
- Integrated login dialog
- Demo credentials display
- Automatic redirect after successful login
- Prevents access if already logged in
## How It Works
### 1. Route Protection
All protected routes use the `AuthGuard`:
```typescript
{ path: 'dashboard', component: Dashboard, canActivate: [AuthGuard] }
```
### 2. Authentication Flow
1. User tries to access protected route
2. `AuthGuard` checks authentication status
3. If not authenticated:
- Stores attempted URL
- Redirects to `/login`
4. If authenticated:
- Allows access to requested route
### 3. Login Process
1. User lands on `/login` page
2. Clicks "Sign In" button
3. Login dialog opens with MFA support
4. After successful login:
- User data stored in localStorage
- Redirected to originally requested URL or dashboard
### 4. Logout Process
1. User clicks logout from header dropdown
2. `AuthService.logout()` clears user data
3. Redirected to `/login` page
## User State Management
### AuthService Features
- **Persistent Login**: User stays logged in across browser sessions
- **State Management**: Reactive user state with RxJS
- **Redirect URLs**: Remembers where user was trying to go
- **localStorage**: Automatic persistence and restoration
### Header Integration
- **Dynamic User Menu**: Shows different options based on auth state
- **User Information**: Displays current user name/email
- **Logout Button**: Easy access to logout functionality
## Route Structure
```
/login - Public login page
/dashboard - Protected (requires auth)
/schedule - Protected (requires auth)
/patients - Protected (requires auth)
... - All other routes protected
/** - Catch-all redirects to /login
```
## Usage Examples
### Adding New Protected Routes
```typescript
{ path: 'new-feature', component: NewFeatureComponent, canActivate: [AuthGuard] }
```
### Checking Auth State in Components
```typescript
constructor(private authService: AuthService) {}
ngOnInit() {
this.authService.currentUser$.subscribe(user => {
if (user) {
// User is logged in
} else {
// User is not logged in
}
});
}
```
### Manual Logout
```typescript
this.authService.logout();
this.router.navigate(['/login']);
```
## Security Features
- **Route Protection**: All sensitive routes are protected
- **Persistent Sessions**: Users stay logged in across browser sessions
- **Automatic Redirects**: Seamless user experience
- **State Validation**: Authentication state is checked on every route change
- **Clean Logout**: Complete session cleanup on logout
## Testing
Use these test credentials:
- **Direct Login**: `user@example.com` / `password123`
- **MFA Required**: `admin@example.com` / `password123` / `123456`
The system will automatically redirect unauthenticated users to the login page and remember where they were trying to go.
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { AuthService } from '../../shared/services/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) { }
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> | Promise<boolean> | boolean {
// Check if user is authenticated
if (this.authService.isAuthenticated()) {
return true;
}
// Store the attempted URL for redirecting after login
this.authService.setRedirectUrl(state.url);
// Redirect to login page
this.router.navigate(['/login']);
return false;
}
}
@@ -0,0 +1,60 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service';
import { Router } from '@angular/router';
import { ApiConfigService } from '../services/api-config.service';
export const authInterceptor: HttpInterceptorFn = (request, next) => {
const authService = inject(AuthService);
const apiConfigService = inject(ApiConfigService);
const router = inject(Router);
// Get the current user and token
const token = authService.getToken();
// Clone the request and add the Authorization header if token exists
if (token && shouldAddToken(apiConfigService, request)) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
// Handle the request and catch 401 errors
return next(request).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Token is invalid or expired, logout user
authService.logout();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
/**
* Determine if the token should be added to this request
* Skip adding token to login requests and other public endpoints
*/
function shouldAddToken(apiConfigService: ApiConfigService, request: any): boolean {
// Don't add token to outbound requests to other domains
if (!request.url.startsWith(apiConfigService.getBaseUrl())) {
return false;
}
// Don't add token to login requests
if (request.url.includes('/Auth/login') || request.url.includes('/Token/Create')) {
return false;
}
// Don't add token to public endpoints (you can customize this list)
const publicEndpoints = [
'/Auth/register',
'/Auth/forgot-password',
'/Auth/reset-password'
];
return !publicEndpoints.some(endpoint => request.url.includes(endpoint));
}
@@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ApiConfigService {
private readonly baseUrl = environment.apiUrl;
constructor() { }
/**
* Get the full API URL for a specific endpoint
* @param endpoint - The API endpoint (e.g., 'Auth', 'Users', 'Transactions')
* @returns Full API URL
*/
getApiUrl(endpoint: string): string {
return `${this.baseUrl}/${endpoint}`;
}
/**
* Get the base API URL
* @returns Base API URL
*/
getBaseUrl(): string {
return this.baseUrl;
}
/**
* Get specific API endpoints
*/
get authUrl(): string {
return this.getApiUrl('Auth');
}
get tokenUrl(): string {
return this.getApiUrl('Token');
}
get usersUrl(): string {
return this.getApiUrl('Users');
}
get transactionsUrl(): string {
return this.getApiUrl('Transactions');
}
get dashboardUrl(): string {
return this.getApiUrl('Dashboard');
}
get ordersUrl(): string {
return this.getApiUrl('ClientBridgeOrder');
}
get orderDetailUrl(): string {
return this.getApiUrl('OrderDetail');
}
}
@@ -0,0 +1,168 @@
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ApiConfigService } from './api-config.service';
// Type definitions
type TextResponse = { message: string };
/**
* Base CRUD service that targets the provided controller path.
*
* It mirrors the endpoints of CrudBaseApiController<T>:
* GET /api/{controller}
* GET /api/{controller}/{id}
* POST /api/{controller} -> string
* POST /api/{controller}/batch -> string[]
* PUT /api/{controller}
* PUT /api/{controller}/batch -> number
* DELETE /api/{controller}/{id}
* DELETE /api/{controller}/batch -> text summary
* GET /api/{controller}/{id}/exists -> boolean
* GET /api/{controller}/count -> number
*/
@Injectable({ providedIn: 'root' })
export class CrudBaseApiService<T extends object> {
/**
* Example: baseUrl = 'https://your-api', controller = 'Customer' →
* endpoint = 'https://your-api/api/Customer'
*/
protected readonly endpoint: string;
/**
* @param http Angular HttpClient
* @param baseUrl API root without trailing slash (e.g., environment.apiBaseUrl)
* @param controllerName Controller name (e.g., 'Customer', 'Orders')
*/
constructor(
protected http: HttpClient,
protected apiConfig: ApiConfigService,
@Inject(String) private controllerName: string
) {
this.endpoint = apiConfig.getApiUrl(this.controllerName);
}
/** Optional default headers (JSON). Override in subclasses if needed. */
protected get jsonHeaders(): HttpHeaders {
return new HttpHeaders({ 'Content-Type': 'application/json' });
}
/** Shared error handler that surfaces useful messages. */
protected handleError(error: HttpErrorResponse): Observable<never> {
let msg = 'Unknown error';
if (error.error instanceof Blob) {
// In case backend returns text/plain; charset=utf-8 as Blob
return throwError(() => new Error('Server returned an error blob'));
}
if (typeof error.error === 'string') msg = error.error;
else if (error.error?.message) msg = error.error.message;
else if (error.message) msg = error.message;
return throwError(() => new Error(msg));
}
/** Prepare the response for the given entity. Override in subclasses if needed. */
protected prepareResponse(response: T): T {
// Do nothing by default
return response;
}
/** GET /api/{controller} */
getAll(): Observable<T[]> {
return this.http
.get<T[]>(this.endpoint)
.pipe(
map(response => {
for (let i = 0; i < response.length; i++) {
const element = response[i];
response[i] = this.prepareResponse(element);
}
return response;
}),
catchError(err => this.handleError(err)));
}
/** GET /api/{controller}/{id} */
getById(id: string): Observable<T> {
return this.http
.get<T>(`${this.endpoint}/${id}`)
.pipe(
map(response => this.prepareResponse(response)),
catchError(err => this.handleError(err)));
}
/** POST /api/{controller} -> string */
create(entity: T): Observable<string> {
return this.http
.post<string>(this.endpoint, entity, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** POST /api/{controller}/batch -> string[] */
createRange(entities: T[]): Observable<string[]> {
return this.http
.post<string[]>(`${this.endpoint}/batch`, entities, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** PUT /api/{controller} */
update(entity: T): Observable<void> {
return this.http
.put<void>(this.endpoint, entity, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** PUT /api/{controller}/batch -> number (updated count) */
updateRange(entities: T[]): Observable<number> {
return this.http
.put<number>(`${this.endpoint}/batch`, entities, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** DELETE /api/{controller}/{id} */
delete(id: string): Observable<void> {
return this.http
.delete<void>(`${this.endpoint}/${id}`)
.pipe(catchError(err => this.handleError(err)));
}
/** DELETE /api/{controller}/batch -> text summary */
deleteRange(ids: string[]): Observable<TextResponse> {
// API returns a plain text message; map it into a TextResponse for convenience
return this.http
.delete(`${this.endpoint}/batch`, {
body: ids,
headers: this.jsonHeaders
})
.pipe(
map((response: any) => ({ message: response || 'Batch delete completed' })),
catchError(err => this.handleError(err))
);
}
/** GET /api/{controller}/{id}/exists -> boolean */
exists(id: string): Observable<boolean> {
return this.http
.get<boolean>(`${this.endpoint}/${id}/exists`)
.pipe(catchError(err => this.handleError(err)));
}
/** GET /api/{controller}/count -> number */
count(): Observable<number> {
return this.http
.get<number>(`${this.endpoint}/count`)
.pipe(catchError(err => this.handleError(err)));
}
}
@@ -0,0 +1,294 @@
<main
class="k-px-2 k-px-sm-4.5 k-px-md-6 k-px-lg-4 k-px-xl-7.5 k-py-2 k-py-sm-4.5 k-py-md-6 k-py-lg-4 k-py-xl-7.5 k-pt-8 k-bg-light">
<h1 class="k-h1 k-color-primary-emphasis k-overflow-hidden k-text-ellipsis">Dashboard</h1>
<div class="k-d-grid k-grid-cols-12 k-gap-4 k-py-4">
<!-- Start of CMPCTCARD-1 -->
<div *ngFor="let card of compactCards; let i = index;"
class="{{cardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<kendo-svgicon [icon]="card.svgIcon" themeColor="primary" size="xxlarge"></kendo-svgicon>
<div class="k-d-flex k-flex-col">
<span
class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">{{card.title}}</span>
<span class="k-font-size-sm k-line-height-lg k-color-subtle">{{card.info}}</span>
</div>
</div>
<!-- End of CMPCTCARD-1 -->
<!-- Start of DASHBRDCARD-10 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<div class="k-d-flex k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Calendar</span>
</div>
<div class="k-flex-1 k-px-3 k-pb-3 k-d-flex k-justify-content-center">
<kendo-calendar [showOtherMonthDays]="false" type="classic" [(ngModel)]="date2">
</kendo-calendar>
</div>
</div>
<!-- End of DASHBRDCARD-10 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Bed
Occupancy</span>
<kendo-datepicker format="yyyy" [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart style="height: 257px;">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item
[categories]="['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100" [min]="0" [majorTicks]="{step: 10}">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="column" name="Occupied" [spacing]="0"
[legendItem]="{type: 'line' }" [data]="[67, 78, 47, 41, 38, 33]">
</kendo-chart-series-item>
<kendo-chart-series-item type="column" name="Free" [legendItem]="{type: 'line' }"
[data]="[21, 10, 44, 40, 48, 60]">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom" orientation="horizontal" align="start"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-1 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Staff</span>
<kendo-dropdownlist [data]="ddlData" [value]="ddlValue" fillMode="flat" [style.width.px]="164"
[attr.aria-label]="'Select'"></kendo-dropdownlist>
</div>
<div class="k-flex-1 k-px-3">
<kendo-listview [data]="listItems" layout="flex" flexDirection="col" [bordered]="false">
<ng-template kendoListViewItemTemplate let-dataItem>
<div
class="k-d-flex k-border-b k-border-b-solid k-border-border k-gap-3 k-p-2 k-align-items-center">
<kendo-badge-container>
<kendo-avatar [imageSrc]="dataItem.imageSrc"></kendo-avatar>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlignBottomEnd"
themeColor="success"></kendo-badge>
</kendo-badge-container>
<div class="k-d-flex k-flex-col">
<div class="k-font-size-lg">{{dataItem.name}}</div>
<div class="k-font-size-sm k-color-subtle">{{dataItem.specialty}}</div>
</div>
</div>
</ng-template>
</kendo-listview>
</div>
<div class="k-p-3">
<button kendoButton fillMode="flat" themeColor="primary">View all</button>
</div>
</div>
<!-- End of DASHBRDCARD-1 -->
<!-- Start of DASHBRDCARD-4 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span
class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Appointments</span>
<kendo-datepicker [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164" [clearButton]="true"
[inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-d-grid k-grid-cols-12 k-p-4 k-gap-2">
<div *ngFor="let appointment of appointments; let last = last"
[ngClass]="{ 'k-d-none k-d-lg-block' : last }"
class=" k-col-span-12 k-col-span-lg-4 k-bg-light k-border k-border-solid k-border-border k-rounded-sm k-d-flex k-flex-col k-flex-1">
<div class="k-d-flex k-justify-content-between k-p-1.5 k-h-12">
<span class="k-font-medium">{{appointment.doctor}}</span>
<div class="k-flex-shrink-0">
<span class="k-badge k-badge-md k-badge-solid k-badge-solid-primary k-rounded-full">
{{appointment.start}}
</span>
</div>
</div>
<div class="k-d-flex k-flex-col k-flex-1 k-gap-1.5 k-px-1.5">
<div>Appointment with {{appointment.patient.name}}.</div>
<div class="k-font-size-sm">
<div class="k-color-subtle k-d-flex k-gap-1 k-align-items-center k-line-height-lg">
<kendo-svgicon [icon]="envelopeIcon"></kendo-svgicon>
<a class="k-color-inherit" href="#">{{appointment.patient.phone}}</a>
</div>
<div class="k-color-subtle k-d-flex k-gap-1 k-align-items-center k-line-height-lg">
<kendo-svgicon [icon]="envelopeIcon"></kendo-svgicon>
<a class="k-color-inherit" href="#">{{appointment.patient.email}}</a>
</div>
</div>
</div>
<div class="k-d-flex k-flex-shrink-0 k-p-1.5">
<button kendoButton fillMode="clear" themeColor="primary">Edit</button>
<button kendoButton fillMode="clear">Cancel</button>
</div>
</div>
</div>
<div class="k-p-3">
<button kendoButton fillMode="clear" themeColor="primary">View all appointments</button>
</div>
</div>
<!-- End of DASHBRDCARD-4 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Infection
Rate</span>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-x-axis>
<kendo-chart-x-axis-item [labels]="{rotation: -45}"></kendo-chart-x-axis-item>
</kendo-chart-x-axis>
<kendo-chart-series>
<kendo-chart-series-item
*ngFor="let dataSet of ['RSV', 'CDC', 'Measles', 'Influenza', 'Campylobacteriosis', 'Hepatitis']"
type="heatmap" [data]="heatmapData(dataSet)" xField="a" yField="b"
valueField="value"></kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Equipment
Availability</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-series>
<kendo-chart-series-item [autoFit]="true" type="donut" [holeSize]="50" [data]="donutData"
categoryField="kind" field="share">
<kendo-chart-series-item-labels position="outsideEnd" color="#000"
[content]="chartLabelContent"></kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend [visible]="false"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Average Length of
Stay</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="departments">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="14" [majorUnit]="1">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="bar" [data]="averageStay">
</kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Hospital
Visits</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="330">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="hours" baseUnit="hours"
[labels]="{rotation: 270, position: 'start', format: 'h:mm'}">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="line" [data]="hospitalVisits">
</kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Satisfaction
Score</span>
<kendo-dropdownlist [value]="'2023'" [fillMode]="'flat'" [style.width.px]="164"
[attr.aria-label]="'Select'"></kendo-dropdownlist>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="288">
<kendo-chart-series>
<kendo-chart-series-item type="pie" [legendItem]="{type: 'line' }" [data]="satisfaction"
categoryField="kind" field="share" [padding]="10" [border]="{width: 3, color: '#fff'}">
<kendo-chart-series-item-labels position="center">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Mortality
Rate</span>
<kendo-datepicker format="yyyy" [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="288">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="mortalityCauses">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100" [min]="0"
[majorTicks]="{step: 10}"></kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="bar" [legendItem]="{type: 'line' }" name="Male"
[data]="[25, 35, 36, 42, 85, 12, 4, 17, 19, 49, 28]">
</kendo-chart-series-item>
<kendo-chart-series-item type="bar" [legendItem]="{type: 'line' }" name="Female"
[data]="[23, 40, 38, 30, 81, 18, 3, 21, 22, 45, 24]">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom" orientation="horizontal" align="start"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
</div>
</main>
@@ -0,0 +1,86 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChartsModule, SeriesLabelsContentArgs } from '@progress/kendo-angular-charts';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ListViewModule } from '@progress/kendo-angular-listview';
import { BadgeAlign, IndicatorsModule } from '@progress/kendo-angular-indicators';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IconsModule } from '@progress/kendo-angular-icons';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { SVGIcon, envelopeIcon } from '@progress/kendo-svg-icons';
import {
appointments,
averageStay,
compactCards,
departments,
donutData,
heatmapDataCDC,
heatmapDataCampylobacteriosis,
heatmapDataHepatitis,
heatmapDataInfluenza,
heatmapDataMeasles,
heatmapDataRSV,
hospitalVisits,
hours,
listItems,
mortalityCauses,
satisfaction
} from './models';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
ChartsModule,
DateInputsModule,
DropDownsModule,
ListViewModule,
IndicatorsModule,
ButtonsModule,
IconsModule,
LayoutModule
],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class Dashboard {
public cardClasses = 'k-d-flex k-border k-border-solid k-border-border k-bg-surface-alt k-align-items-center k-overflow-x-auto k-p-3 k-gap-6 k-elevation-1 k-rounded-md';
public dashboardClasses = 'k-d-flex k-flex-col k-border k-border-solid k-border-border k-bg-surface-alt k-overflow-x-auto k-elevation-1 k-rounded-md';
public envelopeIcon: SVGIcon = envelopeIcon;
public badgeAlignBottomEnd: BadgeAlign = {
vertical: 'bottom',
horizontal: 'end'
};
public chartLabelContent(e: SeriesLabelsContentArgs): string {
return e.category;
}
public date = new Date(2023, 5, 14);
public date2 = new Date(2023, 5, 15);
public averageStay = averageStay;
public hours = hours
public hospitalVisits = hospitalVisits;
public departments = departments;
public mortalityCauses = mortalityCauses;
public satisfaction = satisfaction;
public donutData = donutData;
public heatmapDataRSV = heatmapDataRSV;
public heatmapDataCDC = heatmapDataCDC;
public heatmapDataMeasles = heatmapDataMeasles;
public heatmapDataInfluenza = heatmapDataInfluenza;
public heatmapDataHepatitis = heatmapDataHepatitis
public heatmapDataCampylobacteriosis = heatmapDataCampylobacteriosis;
public heatmapData = (dataset: string): any[] => (this as any)[`heatmapData${dataset}`];
public appointments = appointments;
public ddlData = ['All Departments'];
public ddlValue = 'All Departments'
public compactCards = compactCards;
public listItems: any[] = listItems;
}
+502
View File
@@ -0,0 +1,502 @@
import { accessibilityIcon, calendarDateIcon, calendarIcon, displayBlockIcon, dollarIcon, fileIcon, inboxIcon, myspaceIcon, pencilIcon, starOutlineIcon } from "@progress/kendo-svg-icons";
export const menuItems = [
"Settings",
"Support",
"Log out"
];
export const averageStay = [4, 3, 2, 14, 5, 7, 5, 6, 12, 1, 4];
export const hours = Array(48).fill({}).map((_, idx) => `${Math.floor(idx / 2)}:${idx % 2 ? '30': '00'}`);
export const hospitalVisits = [14, 20, 20, 26, 30, 26, 29, 32, 31, 29, 31, 35, 36, 40, 42, 45, 61, 63, 65, 66, 67, 67, 63, 64, 63, 62, 60, 45, 52, 55, 48, 44, 38, 35, 31, 35, 36, 40, 42, 55, 50, 41, 41, 39, 31, 32, 23, 27];
export const departments = [
'Pharmacology & Toxicology',
'Gastroenterology',
'Radiology',
'Orthopedics',
'Outpatient',
'Oncology',
'Neurology',
'ICU',
'Cardiology',
'Emergency',
'Delivery'
];
export const mortalityCauses = [
'Pharmacology & Toxicology',
'Oncological diseases',
'Circulatory diseases',
'Injury and poisoning',
'Respiratory diseases',
'Endocrine diseases',
'Digestive diseases',
'Nervous system diseases',
'Infectious diseases',
'Kidney diseases',
'Other causes'
];
export const satisfaction = [
{
kind: 'Very dissatisfied',
share: 60
},
{
kind: 'Dissatisfied',
share: 60
},
{
kind: 'Neutral',
share: 60
},
{
kind: 'Satisfied',
share: 60
},
{
kind: 'Very satisfied',
share: 60
},
{
kind: 'Didn\'t answer',
share: 60
}];
export const donutData = [
{
kind: 'Imaging Equipment',
share: 0.17,
},
{
kind: 'Surgical Instruments',
share: 0.17,
},
{
kind: 'Electromedical Equipment',
share: 0.17,
},
{
kind: 'Transport and Storage',
share: 0.17,
},
{
kind: 'Endoscopic Instruments',
share: 0.17,
},
{
kind: 'Others',
share: 0.17,
}];
export const heatmapDataRSV = [{
a: 'June 2023',
b: 'RSV',
value: 66
}, {
a: 'May 2023',
b: 'RSV',
value: 34
}, {
a: 'Apr 2023',
b: 'RSV',
value: 13
}, {
a: 'Mar 2023',
b: 'RSV',
value: 49
}, {
a: 'Feb 2023',
b: 'RSV',
value: 22
}, {
a: 'Jan 2023',
b: 'RSV',
value: 66
}, {
a: 'Dec 2022',
b: 'RSV',
value: 78
}, {
a: 'Nov 2022',
b: 'RSV',
value: 89
}, {
a: 'Oct 2022',
b: 'RSV',
value: 27
}, {
a: 'Sep 2022',
b: 'RSV',
value: 83
}];
export const heatmapDataCDC = [{
a: 'June 2023',
b: 'CDC',
value: 51
}, {
a: 'May 2023',
b: 'CDC',
value: 84
}, {
a: 'Apr 2023',
b: 'CDC',
value: 32
}, {
a: 'Mar 2023',
b: 'CDC',
value: 16
}, {
a: 'Feb 2023',
b: 'CDC',
value: 11
}, {
a: 'Jan 2023',
b: 'CDC',
value: 55
}, {
a: 'Dec 2022',
b: 'CDC',
value: 99
}, {
a: 'Nov 2022',
b: 'CDC',
value: 42
}, {
a: 'Oct 2022',
b: 'CDC',
value: 30
}, {
a: 'Sep 2022',
b: 'CDC',
value: 10
}];
export const heatmapDataMeasles = [{
a: 'June 2023',
b: 'Measles',
value: 80
}, {
a: 'May 2023',
b: 'Measles',
value: 56
}, {
a: 'Apr 2023',
b: 'Measles',
value: 78
}, {
a: 'Mar 2023',
b: 'Measles',
value: 63
}, {
a: 'Feb 2023',
b: 'Measles',
value: 24
}, {
a: 'Jan 2023',
b: 'Measles',
value: 33
}, {
a: 'Dec 2022',
b: 'Measles',
value: 38
}, {
a: 'Nov 2022',
b: 'Measles',
value: 17
}, {
a: 'Oct 2022',
b: 'Measles',
value: 62
}, {
a: 'Sep 2022',
b: 'Measles',
value: 82
}];
export const heatmapDataInfluenza = [{
a: 'June 2023',
b: 'Influenza',
value: 84
}, {
a: 'May 2023',
b: 'Influenza',
value: 25
}, {
a: 'Apr 2023',
b: 'Influenza',
value: 59
}, {
a: 'Mar 2023',
b: 'Influenza',
value: 74
}, {
a: 'Feb 2023',
b: 'Influenza',
value: 41
}, {
a: 'Jan 2023',
b: 'Influenza',
value: 69
}, {
a: 'Dec 2022',
b: 'Influenza',
value: 71
}, {
a: 'Nov 2022',
b: 'Influenza',
value: 11
}, {
a: 'Oct 2022',
b: 'Influenza',
value: 23
}, {
a: 'Sep 2022',
b: 'Influenza',
value: 43
}];
export const heatmapDataHepatitis = [{
a: 'June 2023',
b: 'Hepatitis',
value: 31
}, {
a: 'May 2023',
b: 'Hepatitis',
value: 27
}, {
a: 'Apr 2023',
b: 'Hepatitis',
value: 16
}, {
a: 'Mar 2023',
b: 'Hepatitis',
value: 74
}, {
a: 'Feb 2023',
b: 'Hepatitis',
value: 50
}, {
a: 'Jan 2023',
b: 'Hepatitis',
value: 6
}, {
a: 'Dec 2022',
b: 'Hepatitis',
value: 22
}, {
a: 'Nov 2022',
b: 'Hepatitis',
value: 65
}, {
a: 'Oct 2022',
b: 'Hepatitis',
value: 37
}, {
a: 'Sep 2022',
b: 'Hepatitis',
value: 13
}];
export const heatmapDataCampylobacteriosis = [{
a: 'June 2023',
b: 'Campylobacteriosis',
value: 66
}, {
a: 'May 2023',
b: 'Campylobacteriosis',
value: 21
}, {
a: 'Apr 2023',
b: 'Campylobacteriosis',
value: 52
}, {
a: 'Mar 2023',
b: 'Campylobacteriosis',
value: 43
}, {
a: 'Feb 2023',
b: 'Campylobacteriosis',
value: 97
}, {
a: 'Jan 2023',
b: 'Campylobacteriosis',
value: 81
}, {
a: 'Dec 2022',
b: 'Campylobacteriosis',
value: 28
}, {
a: 'Nov 2022',
b: 'Campylobacteriosis',
value: 34
}, {
a: 'Oct 2022',
b: 'Campylobacteriosis',
value: 45
}, {
a: 'Sep 2022',
b: 'Campylobacteriosis',
value: 18
}];
export const appointments = [{
doctor: 'Dr. Terrell Fashey',
start: '8:30 AM',
patient: {
name: 'Flora Strosin',
phone: '679-747-6105',
email: 'flora.strosin@email.com'
}
}, {
doctor: 'Dr. Clarence Gulgowski',
start: '9:10 AM',
patient: {
name: 'Michele Nicolas',
phone: '884-528-7089',
email: 'm.nicolas@email.com'
}
}, {
doctor: 'Dr. Jay Mohr',
start: '9:45 AM',
patient: {
name: 'Joseph Pacocha',
phone: '777-284-2912',
email: 'j.pacocha@email.com'
}
}];
export const compactCards = [{
svgIcon: calendarIcon,
title: 'Appointments',
info: '78 appointments today'
}, {
svgIcon: accessibilityIcon,
title: 'Patients',
info: '1234 active cases'
}, {
svgIcon: displayBlockIcon,
title: 'Beds',
info: '56 occupied beds'
}, {
svgIcon: myspaceIcon,
title: 'Staff',
info: '78 colleagues at work'
}];
export const listItems = [{
name: 'Dr. Teresa Conn',
specialty: 'Internal medicine',
imageSrc: 'assets/healthcare-dashboard/avatar_1.png'
}, {
name: 'Dr. Mitchell Robel',
specialty: 'Pediatrics',
imageSrc: 'assets/healthcare-dashboard/avatar_2.png'
}, {
name: 'Dr. Barry Jacobs',
specialty: 'Gastroenterology',
imageSrc: 'assets/healthcare-dashboard/avatar_3.png'
}, {
name: 'Dr. Nina Bosco',
specialty: 'Cardiology',
imageSrc: 'assets/healthcare-dashboard/avatar_4.png'
}];
export const drawerItems = [{
text: 'Dashboard',
svgIcon: inboxIcon,
selected: true,
id: 0,
}, {
text: 'Schedule',
svgIcon: calendarDateIcon,
id: 1
}, {
text: 'Patients',
svgIcon: accessibilityIcon,
id: 2,
}, {
text: 'Bed Management',
svgIcon: displayBlockIcon,
id: 3
}, {
text: 'Staff',
svgIcon: myspaceIcon,
id: 4,
}, {
text: 'Doctors',
svgIcon: accessibilityIcon,
id: 40,
parentId: 4
}, {
text: 'Nurses',
svgIcon: accessibilityIcon,
id: 41,
parentId: 4
}, {
text: 'Therapists',
svgIcon: accessibilityIcon,
id: 42,
parentId: 4
}, {
text: 'Technicians',
svgIcon: accessibilityIcon,
id: 43,
parentId: 4
}, {
text: 'Information technology',
svgIcon: accessibilityIcon,
id: 44,
parentId: 4
}, {
text: 'Food services',
svgIcon: accessibilityIcon,
id: 45,
parentId: 4
}, {
text: 'Environmental services',
svgIcon: accessibilityIcon,
id: 46,
parentId: 4
}, {
text: 'Pharmacy',
svgIcon: pencilIcon,
id: 5,
}, {
text: 'Reports',
svgIcon: fileIcon,
id: 6,
}, {
text: 'Report 1',
svgIcon: fileIcon,
id: 60,
parentId: 6
}, {
text: 'Departments',
svgIcon: calendarIcon,
id: 7,
}, {
text: 'Report 1',
svgIcon: calendarIcon,
id: 70,
parentId: 7
}, {
text: 'Payments',
svgIcon: dollarIcon,
id: 8,
}, {
text: 'Payments 1',
svgIcon: dollarIcon,
id: 80,
parentId: 8
}, {
separator: true
}, {
text: 'Support',
svgIcon: starOutlineIcon,
id: 9,
}];
@@ -0,0 +1,238 @@
<div class="login-page-container">
<!-- Background Elements -->
<div class="background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Content -->
<div class="login-content">
<!-- Left Side - Branding -->
<div class="branding-section">
<div class="branding-content">
<div class="logo-container">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image">
<div class="logo-text">
<h1>RBJ Identity</h1>
<span class="tagline">Escrow Management Portal</span>
</div>
</div>
<div class="welcome-text">
<h2>Welcome Back</h2>
<p>Access your escrow transactions, manage client communications, and track document workflows
securely.</p>
</div>
<div class="features-list">
<div class="feature-item">
<div class="feature-icon">🔒</div>
<span>Secure Escrow Management</span>
</div>
<div class="feature-item">
<div class="feature-icon">💬</div>
<span>Client Communication</span>
</div>
<div class="feature-item">
<div class="feature-icon">📄</div>
<span>Document Management</span>
</div>
<div class="feature-item">
<div class="feature-icon">📋</div>
<span>Task Tracking</span>
</div>
</div>
</div>
</div>
<!-- Right Side - Login Form -->
<div class="login-section">
<div class="login-card">
<!-- Initial State -->
<div *ngIf="!showLoginForm" class="initial-state">
<div class="login-header">
<h3>Access Your Account</h3>
<p>Sign in to manage your escrow transactions and client communications</p>
</div>
<div class="login-actions">
<button kendoButton themeColor="primary" size="large" (click)="showLoginFormView()"
class="signin-button">
<span class="button-content">
<svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
Sign In
</span>
</button>
</div>
</div>
<!-- Login Form State -->
<div *ngIf="showLoginForm" class="login-form-state">
<div class="login-header">
<button class="back-button" (click)="goBackToInitialState()" title="Go back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15,18 9,12 15,6"></polyline>
</svg>
</button>
<h3>Sign In</h3>
<p>Enter your credentials to access your account</p>
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
<!-- Error Message -->
<div *ngIf="showError" 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>
{{ errorMessage }}
</div>
<!-- Email Field -->
<div class="form-field">
<kendo-label for="email">Email Address</kendo-label>
<kendo-textbox id="email" formControlName="email" placeholder="Enter your email address"
[clearButton]="false">
</kendo-textbox>
<div *ngIf="emailControl?.invalid && emailControl?.touched" class="field-error">
<span *ngIf="emailControl?.errors?.['required']">Email is required</span>
<span *ngIf="emailControl?.errors?.['email']">Please enter a valid email address</span>
</div>
</div>
<!-- Password Field -->
<div class="form-field">
<kendo-label for="password">Password</kendo-label>
<kendo-textbox id="password" formControlName="password" placeholder="Enter your password"
type="password" [clearButton]="false">
</kendo-textbox>
<div *ngIf="passwordControl?.invalid && passwordControl?.touched" class="field-error">
<span *ngIf="passwordControl?.errors?.['required']">Password is required</span>
<span *ngIf="passwordControl?.errors?.['minlength']">Password must be at least 6
characters</span>
</div>
</div>
<!-- Remember Me -->
<div class="form-field checkbox-field">
<label class="checkbox-container">
<kendo-checkbox formControlName="rememberMe"></kendo-checkbox>
<span class="checkbox-label">Remember me</span>
</label>
</div>
<!-- Submit Button -->
<div class="form-actions">
<button kendoButton themeColor="primary" size="large" type="submit"
[disabled]="loginForm.invalid || isProcessing" class="submit-button">
<span class="button-content">
<kendo-loader *ngIf="isProcessing" size="small"></kendo-loader>
<svg *ngIf="!isProcessing" class="button-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
{{ isProcessing ? 'Signing In...' : 'Sign In' }}
</span>
</button>
</div>
</form>
</div>
<!-- Demo Credentials *ngIf="!showLoginForm"-->
<div class="demo-section" *ngIf="false">
<div class="demo-header">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
<span>Demo Access</span>
</div>
<div class="credential-tabs">
<button class="tab-button active" (click)="setActiveTab('user')"
[class.active]="activeTab === 'user'">
Client Access
</button>
<button class="tab-button" (click)="setActiveTab('admin')"
[class.active]="activeTab === 'admin'">
Admin Access
</button>
</div>
<div class="credential-content" *ngIf="activeTab === 'user'">
<div class="credential-item">
<span class="label">Client Email:</span>
<span class="value">client@example.com</span>
<button class="copy-btn" (click)="copyToClipboard('client@example.com')" title="Copy email">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Password:</span>
<span class="value">password123</span>
<button class="copy-btn" (click)="copyToClipboard('password123')" title="Copy password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
</div>
<div class="credential-content" *ngIf="activeTab === 'admin'">
<div class="credential-item">
<span class="label">Admin Email:</span>
<span class="value">admin@example.com</span>
<button class="copy-btn" (click)="copyToClipboard('admin@example.com')" title="Copy email">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Password:</span>
<span class="value">password123</span>
<button class="copy-btn" (click)="copyToClipboard('password123')" title="Copy password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Security Code:</span>
<span class="value">123456</span>
<button class="copy-btn" (click)="copyToClipboard('123456')" title="Copy security code">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MFA Dialog -->
<app-mfa-dialog #mfaDialog (mfaSuccess)="onMfaSuccess($event)" (mfaCancel)="onMfaCancel()">
</app-mfa-dialog>
</div>
@@ -0,0 +1,727 @@
.login-page-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
position: relative;
overflow: hidden;
padding: 1rem;
}
// Background Shapes
.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(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&.shape-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
&.shape-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
// Main Content
.login-content {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1200px;
width: 100%;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
overflow: hidden;
position: relative;
z-index: 1;
}
// Branding Section
.branding-section {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
padding: 3rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="white" opacity="0.1"/><circle cx="75" cy="75" r="1" fill="white" opacity="0.1"/><circle cx="50" cy="10" r="0.5" fill="white" opacity="0.1"/><circle cx="10" cy="60" r="0.5" fill="white" opacity="0.1"/><circle cx="90" cy="40" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.3;
}
}
.branding-content {
position: relative;
z-index: 1;
text-align: center;
}
.logo-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
gap: 1rem;
.logo-image {
height: 60px;
width: auto;
filter: brightness(0) invert(1);
}
.logo-text {
h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.tagline {
font-size: 1rem;
opacity: 0.9;
font-weight: 300;
}
}
}
.welcome-text {
margin-bottom: 3rem;
h2 {
font-size: 2rem;
font-weight: 600;
margin: 0 0 1rem 0;
letter-spacing: -0.01em;
}
p {
font-size: 1.1rem;
opacity: 0.9;
line-height: 1.6;
margin: 0;
}
}
.features-list {
display: flex;
flex-direction: column;
gap: 1rem;
.feature-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateX(5px);
}
.feature-icon {
font-size: 1.5rem;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
}
span {
font-weight: 500;
font-size: 1rem;
}
}
}
// Login Section
.login-section {
padding: 3rem;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
h3 {
font-size: 2rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
p {
color: #666;
font-size: 1rem;
margin: 0;
}
}
.login-actions {
margin-bottom: 2rem;
}
.signin-button {
width: 100%;
height: 56px;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(30, 58, 138, 0.4);
}
&:active {
transform: translateY(0);
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 20px;
height: 20px;
}
}
// Demo Section
.demo-section {
background: #f8f9fa;
border-radius: 16px;
padding: 1.5rem;
border: 1px solid #e9ecef;
}
.demo-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #495057;
font-weight: 600;
font-size: 0.9rem;
.demo-icon {
width: 16px;
height: 16px;
}
}
.credential-tabs {
display: flex;
background: white;
border-radius: 8px;
padding: 4px;
margin-bottom: 1rem;
border: 1px solid #e9ecef;
}
.tab-button {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
color: #6c757d;
cursor: pointer;
transition: all 0.2s ease;
&.active {
background: #1e40af;
color: white;
box-shadow: 0 2px 4px rgba(30, 64, 175, 0.2);
}
&:hover:not(.active) {
background: #f8f9fa;
color: #495057;
}
}
.credential-content {
.credential-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: white;
border-radius: 8px;
margin-bottom: 0.5rem;
border: 1px solid #e9ecef;
transition: all 0.2s ease;
&:hover {
border-color: #1e40af;
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.1);
}
.label {
font-weight: 600;
color: #495057;
min-width: 60px;
font-size: 0.9rem;
}
.value {
flex: 1;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem;
color: #1a1a1a;
background: #f8f9fa;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.copy-btn {
background: #1e40af;
border: none;
border-radius: 6px;
padding: 0.5rem;
color: white;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #1e3a8a;
transform: scale(1.05);
}
svg {
width: 14px;
height: 14px;
}
}
}
}
// Login Form Styles
.login-form-state {
.login-header {
position: relative;
text-align: center;
.back-button {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #666;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f8f9fa;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
h3 {
margin: 0 0 0.5rem 0;
}
p {
margin: 0 0 1.5rem 0;
font-size: 0.95rem;
}
}
}
.login-form {
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
background: #fee2e2;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid #fecaca;
margin-bottom: 1.5rem;
font-size: 0.9rem;
font-weight: 500;
.error-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.form-field {
margin-bottom: 1.5rem;
kendo-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
font-size: 0.9rem;
}
kendo-textbox {
width: 100%;
.k-textbox {
height: 48px;
border-radius: 8px;
border: 2px solid #e5e7eb;
font-size: 1rem;
transition: all 0.2s ease;
&:focus {
border-color: #1e40af;
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
}
&.k-invalid {
border-color: #dc2626;
}
}
}
.field-error {
margin-top: 0.25rem;
font-size: 0.8rem;
color: #dc2626;
font-weight: 500;
}
&.checkbox-field {
margin-bottom: 1rem;
.checkbox-container {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
kendo-checkbox {
margin: 0;
}
.checkbox-label {
font-size: 0.9rem;
color: #6b7280;
font-weight: 500;
margin: 0;
}
&:hover .checkbox-label {
color: #1e40af;
}
// Style when checkbox is checked
&:has(kendo-checkbox:checked) .checkbox-label {
color: #1e40af;
}
}
}
}
.form-actions {
margin-top: 2rem;
.submit-button {
width: 100%;
height: 48px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
box-shadow: 0 4px 12px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px 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;
}
}
}
}
// Mobile Responsive
@media (max-width: 768px) {
.login-page-container {
padding: 0.5rem;
}
.login-content {
grid-template-columns: 1fr;
border-radius: 16px;
}
.branding-section {
padding: 2rem 1.5rem;
order: 2;
}
.login-section {
padding: 2rem 1.5rem;
order: 1;
}
.logo-container {
flex-direction: column;
gap: 0.5rem;
.logo-text h1 {
font-size: 2rem;
}
}
.welcome-text {
margin-bottom: 2rem;
h2 {
font-size: 1.5rem;
}
p {
font-size: 1rem;
}
}
.features-list {
.feature-item {
padding: 0.5rem;
.feature-icon {
width: 35px;
height: 35px;
font-size: 1.25rem;
}
span {
font-size: 0.9rem;
}
}
}
.login-header h3 {
font-size: 1.75rem;
}
.signin-button {
height: 50px;
font-size: 1rem;
}
.demo-section {
padding: 1rem;
}
.credential-tabs {
.tab-button {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
}
.credential-content {
.credential-item {
padding: 0.5rem;
.label {
min-width: 50px;
font-size: 0.85rem;
}
.value {
font-size: 0.85rem;
}
}
}
// Login form mobile styles
.login-form-state {
.login-header {
.back-button {
padding: 0.4rem;
svg {
width: 18px;
height: 18px;
}
}
}
}
.login-form {
.form-field {
margin-bottom: 1.25rem;
kendo-textbox .k-textbox {
height: 44px;
font-size: 0.95rem;
}
}
.form-actions .submit-button {
height: 44px;
font-size: 0.95rem;
}
}
}
@media (max-width: 480px) {
.login-page-container {
padding: 0.25rem;
}
.branding-section,
.login-section {
padding: 1.5rem 1rem;
}
.logo-container .logo-text h1 {
font-size: 1.75rem;
}
.welcome-text h2 {
font-size: 1.25rem;
}
.features-list {
.feature-item {
.feature-icon {
width: 30px;
height: 30px;
font-size: 1rem;
}
span {
font-size: 0.85rem;
}
}
}
// Login form extra small mobile styles
.login-form {
.form-field {
margin-bottom: 1rem;
kendo-textbox .k-textbox {
height: 40px;
font-size: 0.9rem;
}
}
.form-actions .submit-button {
height: 40px;
font-size: 0.9rem;
}
}
}
@@ -0,0 +1,205 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DialogModule, DialogService } from '@progress/kendo-angular-dialog';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { MfaDialogComponent } from '../../shared/mfa-dialog/mfa-dialog.component';
import { AuthService, LoginCredentials, LoginResultType, TokenVerificationResult } from '../../shared/services/auth.service';
import { Router, ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-login-page',
standalone: true,
imports: [
CommonModule,
DialogModule,
ButtonsModule,
ReactiveFormsModule,
InputsModule,
LabelModule,
IndicatorsModule,
MfaDialogComponent
],
templateUrl: './login-page.component.html',
styleUrls: ['./login-page.component.scss']
})
export class LoginPage implements OnInit {
@ViewChild('mfaDialog') mfaDialog!: MfaDialogComponent;
activeTab: 'user' | 'admin' = 'user';
showLoginForm = false;
loginForm: FormGroup;
isProcessing = false;
showError = false;
errorMessage = '';
constructor(
private dialogService: DialogService,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute,
private fb: FormBuilder
) {
this.loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
rememberMe: [false]
});
}
ngOnInit(): void {
// Check if user is already logged in
if (this.authService.isAuthenticated()) {
this.redirectToDashboard();
return;
}
// Check for token in URL parameters
this.route.queryParams.subscribe(params => {
const token = params['token'];
if (token) {
this.verifySecretLinkToken(token);
}
});
}
setActiveTab(tab: 'user' | 'admin'): void {
this.activeTab = tab;
}
copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).then(() => {
// You could add a toast notification here
console.log('Copied to clipboard:', text);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
}
showLoginFormView(): void {
this.showLoginForm = true;
// Focus on email input when form appears
setTimeout(() => {
const emailInput = document.querySelector('input[formControlName="email"]') as HTMLInputElement;
if (emailInput) {
emailInput.focus();
}
}, 100);
}
goBackToInitialState(): void {
this.showLoginForm = false;
this.loginForm.reset();
this.showError = false;
this.errorMessage = '';
}
onSubmit(): void {
if (this.loginForm.valid && !this.isProcessing) {
this.isProcessing = true;
this.showError = false;
const credentials: LoginCredentials = this.loginForm.value;
this.authService.login(credentials).subscribe({
next: (result) => {
this.isProcessing = false;
if (result.result === LoginResultType.Success) {
this.authService.setCurrentUser(result.responseData!);
this.redirectToDashboard();
} else if (result.result === LoginResultType.MfaRequired) {
this.showMfaDialog(credentials);
} else {
this.showError = true;
this.errorMessage = result.message || 'Invalid email or password';
}
},
error: (error) => {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'An error occurred during login. Please try again.';
console.error('Login error:', error);
}
});
}
}
get emailControl() {
return this.loginForm.get('email');
}
get passwordControl() {
return this.loginForm.get('password');
}
private showMfaDialog(credentials: LoginCredentials): void {
if (this.mfaDialog) {
// Set the login data for MFA dialog
(this.mfaDialog as any).loginData = credentials;
// Show MFA dialog
this.mfaDialog.show();
}
}
onMfaSuccess(userData: any): void {
this.authService.setCurrentUser(userData);
this.redirectToDashboard();
}
onMfaCancel(): void {
// Reset form and focus on email
this.loginForm.reset();
setTimeout(() => {
const emailInput = document.querySelector('input[formControlName="email"]') as HTMLInputElement;
if (emailInput) {
emailInput.focus();
}
}, 100);
}
private verifySecretLinkToken(token: string): void {
this.isProcessing = true;
this.showError = false;
// First check if token is expired locally
if (this.authService.isTokenExpired(token)) {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'This link has expired. Please request a new one.';
return;
}
this.authService.verifySecretLinkToken(token).subscribe({
next: (result: TokenVerificationResult) => {
this.isProcessing = false;
if (result.isValid && result.user) {
// Token is valid, set user and redirect
this.authService.setCurrentUser(result.user);
this.redirectToDashboard();
} else {
// Token verification failed
this.showError = true;
this.errorMessage = result.message || 'Invalid or expired link. Please request a new one.';
}
},
error: (error) => {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'An error occurred while verifying the link. Please try again.';
console.error('Token verification error:', error);
}
});
}
private redirectToDashboard(): void {
const redirectUrl = this.authService.getRedirectUrl();
this.router.navigate([redirectUrl || '/dashboard']);
}
}
@@ -0,0 +1,5 @@
<!-- Start of FTR-7 -->
<footer class="!k-bg-primary k-color-white k-bg-light k-py-6 k-px-2 k-px-sm-4.5 k-px-md-6 k-px-lg-4 k-px-xl-7.5">
<p class="!k-mb-0">Copyright © {{ currentYear }} RBJ Software, Inc. All rights reserved.</p>
</footer>
<!-- End of FTR-7 -->
@@ -0,0 +1,3 @@
footer {
margin-top: auto;
}
@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-footer',
standalone: true,
imports: [CommonModule],
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent {
public currentYear = new Date().getFullYear();
}
@@ -0,0 +1,42 @@
<!-- Start of TPNAV-1 -->
<header>
<kendo-appbar positionMode='sticky' themeColor="inherit" class='k-bg-surface-alt' [style.z-index]="10000">
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-gap-2">
<button kendoButton [svgIcon]="menuIcon" fillMode="clear" title="Menu" (click)="onMenuClick()"></button>
<a href="#" class="k-d-none k-d-sm-flex logo-link">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity logo" />
<span class="logo-text">RBJ Identity Portal</span>
</a>
<a href="#" class="k-d-flex k-d-sm-none">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity compact logo" />
</a>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-center">
<div class="k-d-flex k-d-md-none">
<button kendoButton [svgIcon]="searchIcon" fillMode="clear" title="Search"></button>
</div>
<div class="k-d-none k-d-md-flex search-box-wrapper">
<kendo-textbox class="search-box" placeholder="Input value" fillMode="flat">
<ng-template kendoTextBoxPrefixTemplate>
<kendo-svgicon [icon]="searchIcon"></kendo-svgicon>
<kendo-textbox-separator></kendo-textbox-separator>
</ng-template>
</kendo-textbox>
</div>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-end k-gap-1.5">
<kendo-badge-container>
<button kendoButton [svgIcon]="bellIcon" fillMode="clear" title="Notifications"></button>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlign" themeColor="error"></kendo-badge>
</kendo-badge-container>
<span class="k-appbar-separator k-color-border k-d-none k-d-sm-inline"></span>
<kendo-dropdownbutton [data]="userMenuItems" fillMode="clear" [svgIcon]="userIcon" [arrowIcon]="true"
(itemClick)="onUserMenuClick($event)">
<span class="k-d-none k-d-sm-inline">
{{ isAuthenticated ? (getDisplayName() || currentUser?.email || 'User') : 'Sign In' }}
</span>
</kendo-dropdownbutton>
</kendo-appbar-section>
</kendo-appbar>
</header>
<!-- End of TPNAV-1 -->
@@ -0,0 +1,48 @@
/* Logo styling */
.logo-link {
//display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.85;
}
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #0066cc 0%, #0052a3 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
white-space: nowrap;
// Fallback for browsers that don't support background-clip
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
color: #0066cc;
background: none;
-webkit-text-fill-color: initial;
}
}
/* Search box responsive styling */
.search-box-wrapper {
width: 100%;
max-width: 360px;
}
.search-box {
width: 100%;
}
/* Mobile optimizations */
@media (max-width: 767px) {
/* Optimize spacing on mobile */
kendo-appbar {
padding: 0 8px;
}
}
@@ -0,0 +1,128 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AppBarModule } from '@progress/kendo-angular-navigation';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, bellIcon, menuIcon, searchIcon, userIcon, logoutIcon } from '@progress/kendo-svg-icons';
import { LayoutService } from '../services/layout.service';
import { AuthService, User } from '../../shared/services/auth.service';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-header',
standalone: true,
imports: [
CommonModule,
AppBarModule,
ButtonsModule,
IndicatorsModule,
InputsModule,
IconsModule,
DropDownsModule
],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit, OnDestroy {
public menuIcon: SVGIcon = menuIcon;
public searchIcon: SVGIcon = searchIcon;
public bellIcon: SVGIcon = bellIcon;
public userIcon: SVGIcon = userIcon;
public logoutIcon: SVGIcon = logoutIcon;
public userMenuItems: any[] = [];
public currentUser: User | null = null;
public isAuthenticated = false;
public badgeAlign = {
vertical: 'top' as const,
horizontal: 'end' as const
};
private destroy$ = new Subject<void>();
constructor(
public layoutService: LayoutService,
private authService: AuthService,
private router: Router
) { }
ngOnInit(): void {
// Subscribe to authentication state changes
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
this.isAuthenticated = !!user;
this.updateUserMenu();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public onMenuClick(): void {
this.layoutService.toggleDrawer();
}
public onLogout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
public onUserMenuClick(item: any): void {
if (item.click) {
item.click();
}
}
public getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
private updateUserMenu(): void {
if (this.isAuthenticated && this.currentUser) {
this.userMenuItems = [
{
text: `Welcome, ${this.getDisplayName() || this.currentUser.email}`,
disabled: true
},
{ separator: true },
{
text: 'Profile',
icon: 'user',
disabled: true
},
{
text: 'Settings',
icon: 'settings',
disabled: true
},
{ separator: true },
{
text: 'Logout',
icon: 'logout',
click: () => this.onLogout()
}
];
} else {
this.userMenuItems = [
{
text: 'Sign In',
click: () => this.router.navigate(['/login'])
}
];
}
}
}
@@ -0,0 +1,23 @@
<kendo-drawer-container>
<kendo-drawer class="!k-flex-none k-overflow-y-auto !k-pos-sticky" [items]="drawerItems"
[mode]="layoutService.drawerMode()" [mini]="true" [expanded]="layoutService.drawerExpanded()"
(select)="onSelect($event)" [autoCollapse]="layoutService.drawerAutoCollapse()"
[isItemExpanded]="isItemExpanded" [width]="248" [style.height]="'calc(100vh - 46px)'">
<ng-template kendoDrawerItemTemplate let-item let-hasChildren="hasChildren" let-isItemExpanded="isItemExpanded">
@if (item.svgIcon) {
<kendo-svgicon [icon]="item.svgIcon"></kendo-svgicon>
}
<span class="k-item-text">{{item.text}}</span>
@if (hasChildren) {
<span class="k-spacer"></span>
<span class="k-drawer-toggle">
<kendo-svgicon [icon]="isItemExpanded ? chevronUpIcon : chevronDownIcon"></kendo-svgicon>
</span>
}
</ng-template>
</kendo-drawer>
<kendo-drawer-content>
<router-outlet></router-outlet>
<app-footer></app-footer>
</kendo-drawer-content>
</kendo-drawer-container>
@@ -0,0 +1,20 @@
/* Drawer animation */
kendo-drawer {
transition: transform 0.3s ease-in-out;
}
/* Mobile optimizations */
@media (max-width: 767px) {
/* Ensure drawer overlay has proper z-index */
kendo-drawer.k-drawer-overlay {
z-index: 9999;
}
}
/* Tablet optimizations */
@media (min-width: 768px) and (max-width: 1023px) {
/* Adjust drawer width for tablets if needed */
kendo-drawer {
max-width: 280px;
}
}
@@ -0,0 +1,77 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterOutlet } from '@angular/router';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, chevronDownIcon, chevronUpIcon } from '@progress/kendo-svg-icons';
import { DrawerItemExpandedFn, DrawerSelectEvent } from '@progress/kendo-angular-layout';
import { LayoutService } from '../services/layout.service';
import { drawerItems } from '../../features/dashboard/models';
import { FooterComponent } from '../footer/footer.component';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
LayoutModule,
IconsModule,
FooterComponent
],
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss']
})
export class NavbarComponent {
public chevronUpIcon: SVGIcon = chevronUpIcon;
public chevronDownIcon: SVGIcon = chevronDownIcon;
public drawerItems = drawerItems;
public selectedDrawerItem = 'Dashboard';
public expandedItems: Array<number> = [4];
constructor(
private router: Router,
public layoutService: LayoutService
) { }
public onSelect(ev: DrawerSelectEvent): void {
this.selectedDrawerItem = ev.item.text;
const current = ev.item.id;
if (this.expandedItems.indexOf(current) >= 0) {
this.expandedItems = this.expandedItems.filter((id) => id !== current);
} else {
this.expandedItems.push(current);
}
// Auto-collapse drawer on mobile after selection
if (this.layoutService.isMobile()) {
this.layoutService.closeDrawer();
}
// Navigate based on the selected item
const routeMap: { [key: string]: string } = {
'Dashboard': '/dashboard',
'Schedule': '/schedule',
'Patients': '/patients',
'Bed Management': '/bed-management',
'Staff': '/staff',
'Pharmacy': '/pharmacy',
'Reports': '/reports',
'Departments': '/departments',
'Payments': '/payments',
'Support': '/support'
};
const route = routeMap[ev.item.text];
if (route) {
this.router.navigate([route]);
}
}
public isItemExpanded: DrawerItemExpandedFn = (item): boolean => {
return this.expandedItems.indexOf(item.id) >= 0;
};
}
@@ -0,0 +1,84 @@
import { Injectable, signal, computed } from '@angular/core';
import { DrawerMode } from '@progress/kendo-angular-layout';
@Injectable({
providedIn: 'root'
})
export class LayoutService {
// Signals for reactive state management
private readonly windowWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 1024);
// Computed signals for responsive breakpoints
public readonly isMobile = computed(() => this.windowWidth() < 768);
public readonly isTablet = computed(() => this.windowWidth() >= 768 && this.windowWidth() < 1024);
public readonly isDesktop = computed(() => this.windowWidth() >= 1024);
// Drawer state
public readonly drawerExpanded = signal<boolean>(true);
public readonly drawerMode = computed<DrawerMode>(() =>
this.isMobile() ? 'overlay' : 'push'
);
public readonly drawerAutoCollapse = computed<boolean>(() => this.isMobile());
constructor() {
this.initializeResizeListener();
this.updateDrawerState();
}
/**
* Initialize window resize listener
*/
private initializeResizeListener(): void {
if (typeof window !== 'undefined') {
window.addEventListener('resize', () => this.handleResize());
}
}
/**
* Handle window resize events
*/
private handleResize(): void {
this.windowWidth.set(window.innerWidth);
this.updateDrawerState();
}
/**
* Update drawer state based on screen size
*/
private updateDrawerState(): void {
if (this.isMobile()) {
this.drawerExpanded.set(false);
} else {
this.drawerExpanded.set(true);
}
}
/**
* Toggle drawer open/closed state
*/
public toggleDrawer(): void {
this.drawerExpanded.update(expanded => !expanded);
}
/**
* Close drawer (useful for mobile after navigation)
*/
public closeDrawer(): void {
this.drawerExpanded.set(false);
}
/**
* Open drawer
*/
public openDrawer(): void {
this.drawerExpanded.set(true);
}
/**
* Get current window width
*/
public getWindowWidth(): number {
return this.windowWidth();
}
}
@@ -0,0 +1,40 @@
<header>
<kendo-appbar positionMode='sticky' themeColor="inherit" class='k-bg-surface-alt' [style.z-index]="10000">
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-gap-2">
<button kendoButton [svgIcon]="menuIcon" fillMode="clear" title="Menu" (click)="onMenuClick()"></button>
<a href="#" class="k-d-none k-d-sm-flex logo-link">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity logo" />
<span class="logo-text">RBJ Identity Portal</span>
</a>
<a href="#" class="k-d-flex k-d-sm-none">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ RBJ Identity compact logo" />
</a>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-center">
<div class="k-d-flex k-d-md-none">
<button kendoButton [svgIcon]="searchIcon" fillMode="clear" title="Search"></button>
</div>
<div class="k-d-none k-d-md-flex search-box-wrapper">
<kendo-textbox class="search-box" placeholder="Search..." fillMode="flat">
<ng-template kendoTextBoxPrefixTemplate>
<kendo-svgicon [icon]="searchIcon"></kendo-svgicon>
<kendo-textbox-separator></kendo-textbox-separator>
</ng-template>
</kendo-textbox>
</div>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-end k-gap-1.5">
<kendo-badge-container>
<button kendoButton [svgIcon]="bellIcon" fillMode="clear" title="Notifications"></button>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlign" themeColor="error"></kendo-badge>
</kendo-badge-container>
<span class="k-appbar-separator k-color-border k-d-none k-d-sm-inline"></span>
<kendo-dropdownbutton [data]="userMenuItems" fillMode="clear" [svgIcon]="userIcon" [arrowIcon]="true"
(itemClick)="onUserMenuClick($event)">
<span class="k-d-none k-d-sm-inline">
{{ getDisplayName() || currentUser?.email || 'User' }}
</span>
</kendo-dropdownbutton>
</kendo-appbar-section>
</kendo-appbar>
</header>
@@ -0,0 +1,21 @@
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
.logo-text {
margin-left: 0.5rem;
font-weight: 600;
font-size: 1.1rem;
}
}
.search-box-wrapper {
width: 100%;
max-width: 400px;
.search-box {
width: 100%;
}
}
@@ -0,0 +1,118 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AppBarModule } from '@progress/kendo-angular-navigation';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, bellIcon, menuIcon, searchIcon, userIcon, logoutIcon } from '@progress/kendo-svg-icons';
import { AuthService, User } from '../../../../shared/services/auth.service';
import { LayoutService } from '../../../../layout/services/layout.service';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-user-header',
standalone: true,
imports: [
CommonModule,
AppBarModule,
ButtonsModule,
IndicatorsModule,
InputsModule,
IconsModule,
DropDownsModule
],
templateUrl: './user-header.component.html',
styleUrls: ['./user-header.component.scss']
})
export class UserHeaderComponent implements OnInit, OnDestroy {
public menuIcon: SVGIcon = menuIcon;
public searchIcon: SVGIcon = searchIcon;
public bellIcon: SVGIcon = bellIcon;
public userIcon: SVGIcon = userIcon;
public logoutIcon: SVGIcon = logoutIcon;
public userMenuItems: any[] = [];
public currentUser: User | null = null;
public badgeAlign = {
vertical: 'top' as const,
horizontal: 'end' as const
};
private destroy$ = new Subject<void>();
constructor(
private authService: AuthService,
private layoutService: LayoutService,
private router: Router
) { }
ngOnInit(): void {
// Subscribe to authentication state changes
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
this.updateUserMenu();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public onMenuClick(): void {
this.layoutService.toggleDrawer();
}
public onLogout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
public onUserMenuClick(item: any): void {
if (item.click) {
item.click();
}
}
public getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
private updateUserMenu(): void {
if (this.currentUser) {
this.userMenuItems = [
{
text: `Welcome, ${this.getDisplayName() || this.currentUser.email}`,
disabled: true
},
{ separator: true },
{
text: 'Profile',
icon: 'user',
disabled: true
},
{
text: 'Settings',
icon: 'settings',
disabled: true
},
{ separator: true },
{
text: 'Logout',
icon: 'logout',
click: () => this.onLogout()
}
];
}
}
}
@@ -0,0 +1,42 @@
<kendo-drawer-container>
<kendo-drawer [mode]="'overlay'" [expanded]="layoutService.drawerExpanded()" [width]="280">
<kendo-drawer-content>
<div class="drawer-content">
<div class="drawer-header">
<h3>User Portal</h3>
<p>RBJ Identity Portal</p>
</div>
<nav class="drawer-nav">
<div class="nav-section">
<h4>Main</h4>
<button *ngFor="let item of mainNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
<div class="nav-section">
<h4>Management</h4>
<button *ngFor="let item of managementNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
<div class="nav-section">
<h4>Support</h4>
<button *ngFor="let item of supportNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
</nav>
</div>
</kendo-drawer-content>
</kendo-drawer>
</kendo-drawer-container>
@@ -0,0 +1,66 @@
.drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
.drawer-header {
padding: 1.5rem 1rem;
border-bottom: 1px solid #e0e0e0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
h3 {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
}
}
.drawer-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.nav-section {
margin-bottom: 1.5rem;
h4 {
margin: 0 0 0.5rem 0;
padding: 0 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #666;
}
}
.nav-button {
width: 100%;
justify-content: flex-start;
text-align: left;
padding: 0.75rem 1rem;
margin: 0.125rem 0;
border-radius: 0;
&:hover {
background-color: #f8f9fa;
}
&.k-button-solid {
background-color: #e3f2fd;
color: #1976d2;
&:hover {
background-color: #bbdefb;
}
}
}
@@ -0,0 +1,106 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd } from '@angular/router';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, homeIcon, calendarIcon, userIcon } from '@progress/kendo-svg-icons';
import { LayoutService } from '../../../../layout/services/layout.service';
import { Subject, takeUntil, filter } from 'rxjs';
interface NavItem {
text: string;
icon: SVGIcon;
path: string;
active?: boolean;
}
@Component({
selector: 'app-user-navbar',
standalone: true,
imports: [
CommonModule,
LayoutModule,
ButtonsModule,
IconsModule
],
templateUrl: './user-navbar.component.html',
styleUrls: ['./user-navbar.component.scss']
})
export class UserNavbarComponent implements OnInit, OnDestroy {
public homeIcon: SVGIcon = homeIcon;
public calendarIcon: SVGIcon = calendarIcon;
public peopleIcon: SVGIcon = userIcon; // Using userIcon as fallback
public bedIcon: SVGIcon = userIcon; // Using userIcon as fallback
public userIcon: SVGIcon = userIcon;
public pillIcon: SVGIcon = userIcon; // Using userIcon as fallback
public chartIcon: SVGIcon = userIcon; // Using userIcon as fallback
public buildingIcon: SVGIcon = userIcon; // Using userIcon as fallback
public creditCardIcon: SVGIcon = userIcon; // Using userIcon as fallback
public supportIcon: SVGIcon = userIcon; // Using userIcon as fallback
public mainNavItems: NavItem[] = [
{ text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' },
{ text: 'Schedule', icon: this.calendarIcon, path: '/user-portal/schedule' },
{ text: 'Patients', icon: this.peopleIcon, path: '/user-portal/patients' }
];
public managementNavItems: NavItem[] = [
{ text: 'Bed Management', icon: this.bedIcon, path: '/user-portal/bed-management' },
{ text: 'Staff', icon: this.userIcon, path: '/user-portal/staff' },
{ text: 'Pharmacy', icon: this.pillIcon, path: '/user-portal/pharmacy' },
{ text: 'Reports', icon: this.chartIcon, path: '/user-portal/reports' },
{ text: 'Departments', icon: this.buildingIcon, path: '/user-portal/departments' },
{ text: 'Payments', icon: this.creditCardIcon, path: '/user-portal/payments' }
];
public supportNavItems: NavItem[] = [
{ text: 'Support', icon: this.supportIcon, path: '/user-portal/support' }
];
private destroy$ = new Subject<void>();
constructor(
public layoutService: LayoutService,
private router: Router
) { }
ngOnInit(): void {
// Listen to route changes to update active states
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe((event: NavigationEnd) => {
this.updateActiveStates(event.url);
});
// Set initial active state
this.updateActiveStates(this.router.url);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public navigateTo(path: string): void {
this.router.navigate([path]);
this.layoutService.closeDrawer();
}
private updateActiveStates(currentUrl: string): void {
// Reset all active states
[...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems]
.forEach(item => item.active = false);
// Set active state for current route
const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems]
.find(item => currentUrl.startsWith(item.path));
if (activeItem) {
activeItem.active = true;
}
}
}
@@ -0,0 +1,172 @@
<div class="dashboard-container">
<!-- Welcome Section -->
<div class="welcome-section">
<div class="welcome-content">
<h1>Welcome back, {{ getDisplayName() || 'User' }}!</h1>
<p>Here's a mock overview of the RBJ Identity escrow dashboard.</p>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ activeTransactions }}</div>
<div class="stat-label">Active Transactions</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon pending">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3 8-8"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ pendingTasks }}</div>
<div class="stat-label">Pending Tasks</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon completed">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22,4 12,14.01 9,11.01"></polyline>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ completedTransactions }}</div>
<div class="stat-label">Completed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon total">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">${{ totalValue | number:'1.0-0' }}</div>
<div class="stat-label">Total Value</div>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="section">
<div class="section-header">
<h2>Recent Transactions</h2>
</div>
<div class="transactions-list">
<!-- Transactions List -->
<div *ngIf="recentTransactions.length > 0">
<div *ngFor="let transaction of recentTransactions" class="transaction-card">
<div class="transaction-header">
<div class="transaction-title">{{ transaction.title }}</div>
<div class="transaction-status" [class]="transaction.status">
{{ transaction.statusLabel }}
</div>
</div>
<div class="transaction-details">
<div class="transaction-amount">${{ transaction.amount | number:'1.0-0' }}</div>
<div class="transaction-date">{{ transaction.date | date:'MMM d, y' }}</div>
</div>
<div class="transaction-progress">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="transaction.progress"></div>
</div>
<span class="progress-text">{{ transaction.progress }}% Complete</span>
</div>
</div>
</div>
<!-- Empty State -->
<div *ngIf="recentTransactions.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<h3>No Recent Transactions</h3>
<p>You don't have any recent transactions yet.</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
<div class="section-header">
<h2>Quick Actions</h2>
</div>
<div class="quick-actions-grid">
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<div class="action-content">
<div class="action-title">New Transaction</div>
<div class="action-description">Start a new escrow process</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3 8-8"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
</div>
<div class="action-content">
<div class="action-title">Manage Tasks</div>
<div class="action-description">View and update your tasks</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="action-content">
<div class="action-title">Contacts</div>
<div class="action-description">Manage your contacts</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<div class="action-content">
<div class="action-title">Messages</div>
<div class="action-description">Check your messages</div>
</div>
</button>
</div>
</div>
</div>
@@ -0,0 +1,536 @@
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
}
// Welcome Section
.welcome-section {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 200px;
height: 200px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform: translate(50%, -50%);
}
.welcome-content {
position: relative;
z-index: 1;
h1 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.01em;
}
p {
margin: 0;
font-size: 1.1rem;
opacity: 0.9;
}
}
.welcome-actions {
position: relative;
z-index: 1;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
svg {
width: 18px;
height: 18px;
}
}
}
// Stats Grid
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.total {
background: rgba(168, 85, 247, 0.1);
color: #9333ea;
}
svg {
width: 24px;
height: 24px;
}
}
.stat-content {
flex: 1;
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
font-weight: 500;
}
}
}
// Section Styles
.section {
margin-bottom: 2rem;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
}
.view-all-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: #1e40af;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1e3a8a;
}
svg {
width: 16px;
height: 16px;
}
}
}
}
// Transactions List
.transactions-list {
display: grid;
gap: 1rem;
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top: 3px solid #1e40af;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
.loading-text {
color: #6b7280;
font-size: 0.9rem;
margin: 0;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
.empty-icon {
width: 64px;
height: 64px;
background: rgba(30, 64, 175, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #1e40af;
margin-bottom: 1.5rem;
svg {
width: 32px;
height: 32px;
}
}
h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
p {
margin: 0 0 1.5rem 0;
color: #6b7280;
font-size: 0.9rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #1e40af;
border: none;
border-radius: 8px;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: #1e3a8a;
transform: translateY(-2px);
}
}
}
.transaction-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 10px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #1e40af;
}
.transaction-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.transaction-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
}
.transaction-status {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
&.active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
}
}
.transaction-details {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.transaction-amount {
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
}
.transaction-date {
font-size: 0.9rem;
color: #6b7280;
}
}
.transaction-progress {
display: flex;
align-items: center;
gap: 1rem;
.progress-bar {
flex: 1;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 0.8rem;
color: #6b7280;
font-weight: 500;
min-width: 80px;
}
}
}
// Quick Actions
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.quick-action-btn {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 1rem;
text-align: left;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #1e40af;
}
.action-icon {
width: 48px;
height: 48px;
background: rgba(30, 64, 175, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #1e40af;
flex-shrink: 0;
svg {
width: 24px;
height: 24px;
}
}
.action-content {
flex: 1;
.action-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
}
.action-description {
font-size: 0.9rem;
color: #6b7280;
}
}
}
// Mobile Responsive
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
text-align: center;
gap: 1.5rem;
.welcome-content h1 {
font-size: 1.5rem;
}
.welcome-content p {
font-size: 1rem;
}
}
.stats-grid {
grid-template-columns: 1fr;
}
.quick-actions-grid {
grid-template-columns: 1fr;
}
.transaction-card {
padding: 1rem;
.transaction-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.transaction-details {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
}
@media (max-width: 480px) {
.welcome-section {
padding: 1.5rem;
.welcome-content h1 {
font-size: 1.25rem;
}
}
.stat-card {
padding: 1rem;
.stat-icon {
width: 40px;
height: 40px;
svg {
width: 20px;
height: 20px;
}
}
.stat-content .stat-value {
font-size: 1.5rem;
}
}
.quick-action-btn {
padding: 1rem;
.action-icon {
width: 40px;
height: 40px;
svg {
width: 20px;
height: 20px;
}
}
}
}
@@ -0,0 +1,75 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService, User } from '../../../../shared/services/auth.service';
interface Transaction {
id: string;
title: string;
amount: number;
status: 'active' | 'pending' | 'completed';
statusLabel: string;
date: Date;
progress: number;
}
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
currentUser: User | null = null;
activeTransactions = 5;
pendingTasks = 12;
completedTransactions = 23;
totalValue = 1250000;
recentTransactions: Transaction[] = [
{
id: 'RBJ-1001',
title: 'Maple Street Purchase Escrow',
amount: 425000,
status: 'active',
statusLabel: 'Open',
date: new Date('2026-04-24'),
progress: 68
},
{
id: 'RBJ-1002',
title: 'Oak Ridge Refinance',
amount: 310000,
status: 'pending',
statusLabel: 'Review',
date: new Date('2026-04-20'),
progress: 42
},
{
id: 'RBJ-1003',
title: 'Cedar Lane Closing',
amount: 515000,
status: 'completed',
statusLabel: 'Closed',
date: new Date('2026-04-12'),
progress: 100
}
];
constructor(private authService: AuthService) { }
ngOnInit(): void {
this.authService.currentUser$.subscribe(user => {
this.currentUser = user;
});
}
getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
}
@@ -0,0 +1,122 @@
<div class="user-portal-container">
<!-- Background Elements -->
<div class="background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Portal Layout -->
<div class="portal-layout">
<!-- Sidebar Overlay for Mobile -->
<div *ngIf="isMobile && !sidebarCollapsed" class="sidebar-overlay" (click)="onSidebarOverlayClick()">
</div>
<!-- Sidebar Navigation -->
<aside class="sidebar" [class.collapsed]="sidebarCollapsed">
<div class="sidebar-header">
<div class="logo-section">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image">
<div class="logo-text" *ngIf="!sidebarCollapsed">
<h2>RBJ Identity</h2>
<span class="tagline">Escrow Portal</span>
</div>
</div>
<button class="sidebar-toggle" (click)="toggleSidebar()" title="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<h4 *ngIf="!sidebarCollapsed">Overview</h4>
<a routerLink="/user-portal/dashboard" routerLinkActive="active" class="nav-item"
(click)="onNavigationClick()">
<div class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</div>
<span *ngIf="!sidebarCollapsed">Dashboard</span>
</a>
</div>
</nav>
<div class="sidebar-footer" *ngIf="!sidebarCollapsed">
<div class="user-info">
<div class="user-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="user-details">
<div class="user-name">{{ getDisplayName() || 'User' }}
</div>
<div class="user-email">{{ currentUser?.email }}</div>
</div>
</div>
<button class="logout-btn" (click)="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16,17 21,12 16,7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Logout
</button>
</div>
</aside>
<!-- Main Content Area -->
<main [class]="mainContentClass">
<!-- Top Header -->
<header class="top-header">
<div class="header-left">
<button class="mobile-menu-btn" (click)="toggleSidebar()" *ngIf="isMobile" title="Toggle menu"
aria-label="Toggle menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="breadcrumb">
<span class="breadcrumb-item">{{ currentPageTitle }}</span>
</div>
</div>
<div class="header-right">
<div class="header-actions">
<button class="action-btn" title="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<div class="notification-badge" *ngIf="unreadNotifications > 0">{{ unreadNotifications }}
</div>
</button>
<button class="action-btn" title="Search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</button>
</div>
</div>
</header>
<!-- Page Content -->
<div class="page-content">
<router-outlet></router-outlet>
</div>
</main>
</div>
</div>
@@ -0,0 +1,554 @@
.user-portal-container {
min-height: 100vh;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
position: relative;
overflow: hidden;
}
// Background Shapes
.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(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&.shape-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
&.shape-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
// Main Portal Layout
.portal-layout {
display: flex;
min-height: 100vh;
position: relative;
z-index: 1;
}
// Desktop layout - fixed sidebar with scrolling content
@media (min-width: 769px) {
.portal-layout {
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
}
.main-content {
margin-left: 290px; // Account for fixed sidebar width
flex: 1;
}
}
// When sidebar is collapsed on desktop
.sidebar.collapsed + .main-content {
margin-left: 70px;
}
}
// Sidebar Overlay for Mobile
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
// Sidebar Styles
.sidebar {
width: 280px;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
transition: all 0.3s ease;
box-shadow: 2px 0 20px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
&.collapsed {
width: 70px;
.logo-text,
.nav-section h4,
.nav-item span,
.user-details,
.logout-btn span {
opacity: 0;
visibility: hidden;
}
.sidebar-footer {
padding: 1rem 0.5rem;
}
.user-info {
justify-content: center;
}
}
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.75rem;
.logo-image {
height: 40px;
width: auto;
//filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) brightness(104%)contrast(97%);
}
.logo-text {
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #1e3a8a;
letter-spacing: -0.01em;
}
.tagline {
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
}
}
}
.sidebar-toggle {
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
.sidebar-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
max-height: calc(100vh - 200px); // Account for header and footer
}
.nav-section {
margin-bottom: 2rem;
h4 {
margin: 0 0 1rem 1.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
}
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: #6b7280;
text-decoration: none;
transition: all 0.2s ease;
position: relative;
margin: 0.125rem 0;
&:hover {
background: rgba(30, 64, 175, 0.1);
color: #1e40af;
}
&.active {
background: rgba(30, 64, 175, 0.15);
color: #1e40af;
font-weight: 600;
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #1e40af;
}
}
.nav-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
span {
font-size: 0.9rem;
font-weight: 500;
}
.nav-badge {
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 10px;
margin-left: auto;
min-width: 20px;
text-align: center;
}
}
.sidebar-footer {
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
.user-avatar {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
svg {
width: 20px;
height: 20px;
}
}
.user-details {
flex: 1;
min-width: 0;
.user-name {
font-weight: 600;
color: #1f2937;
font-size: 0.9rem;
margin-bottom: 0.125rem;
}
.user-email {
font-size: 0.8rem;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.logout-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
&:hover {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
svg {
width: 16px;
height: 16px;
}
}
// Main Content Area
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.95);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
margin: 1rem;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
height: calc(100vh - 2rem);
max-height: calc(100vh - 2rem);
}
.top-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.8);
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
.breadcrumb {
.breadcrumb-item {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn {
position: relative;
background: none;
border: none;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
.notification-badge {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
}
.page-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
height: 100%;
max-height: 100%;
}
// Mobile Responsive
@media (max-width: 768px) {
.portal-layout {
flex-direction: column;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s ease;
&:not(.collapsed) {
transform: translateX(0);
}
&.collapsed {
transform: translateX(-100%);
}
}
.main-content {
margin: 0;
border-radius: 0;
height: 100vh;
max-height: 100vh;
}
.mobile-menu-btn {
display: block;
}
.page-content {
padding: 1rem;
}
.top-header {
padding: 1rem;
}
}
@media (max-width: 480px) {
.page-content {
padding: 0.75rem;
}
.top-header {
padding: 0.75rem;
}
.breadcrumb .breadcrumb-item {
font-size: 1.1rem;
}
}
// Overlay for mobile sidebar
@media (max-width: 768px) {
.sidebar:not(.collapsed)::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
}
// Desktop sidebar collapsed state
@media (min-width: 769px) {
.sidebar.collapsed {
width: 70px;
}
.main-content.sidebar-collapsed {
margin-left: 70px;
}
}
@@ -0,0 +1,139 @@
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd, RouterModule, RouterLink, RouterLinkActive } from '@angular/router';
import { RouterOutlet } from '@angular/router';
import { AuthService, User } from '../../shared/services/auth.service';
import { Subject, takeUntil, filter } from 'rxjs';
@Component({
selector: 'app-user-portal',
standalone: true,
imports: [
CommonModule,
RouterModule,
RouterLink,
RouterLinkActive,
RouterOutlet
],
templateUrl: './user-portal.component.html',
styleUrls: ['./user-portal.component.scss']
})
export class UserPortalComponent implements OnInit, OnDestroy {
sidebarCollapsed = false;
isMobile = false;
currentUser: User | null = null;
currentPageTitle = 'Dashboard';
unreadMessages = 3;
unreadNotifications = 2;
private destroy$ = new Subject<void>();
constructor(
private authService: AuthService,
private router: Router
) { }
ngOnInit(): void {
this.checkScreenSize();
this.setupUserSubscription();
this.setupRouteSubscription();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
@HostListener('window:resize', ['$event'])
onResize(event: any): void {
this.checkScreenSize();
}
private checkScreenSize(): void {
this.isMobile = window.innerWidth <= 768;
if (this.isMobile) {
this.sidebarCollapsed = true;
} else {
// On desktop, start with sidebar expanded
this.sidebarCollapsed = false;
}
}
private setupUserSubscription(): void {
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
});
}
private setupRouteSubscription(): void {
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.updatePageTitle();
});
}
private updatePageTitle(): void {
const url = this.router.url;
const segments = url.split('/').filter(segment => segment);
if (segments.length >= 2) {
const page = segments[1];
this.currentPageTitle = this.getPageTitle(page);
} else {
this.currentPageTitle = 'Dashboard';
}
}
private getPageTitle(page: string): string {
const titles: { [key: string]: string } = {
'dashboard': 'Dashboard',
'transactions': 'Escrow Transactions',
'tasks': 'Tasks & Todos',
'contacts': 'Contacts',
'documents': 'Documents',
'messages': 'Messages',
'settings': 'Settings'
};
return titles[page] || 'Dashboard';
}
toggleSidebar(): void {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
get mainContentClass(): string {
return this.sidebarCollapsed ? 'main-content sidebar-collapsed' : 'main-content';
}
onSidebarOverlayClick(): void {
if (this.isMobile && !this.sidebarCollapsed) {
this.sidebarCollapsed = true;
}
}
onNavigationClick(): void {
if (this.isMobile) {
this.sidebarCollapsed = true;
}
}
logout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
getDisplayName(): string {
if (this.currentUser) {
const fullName = `${this.currentUser.firstName} ${this.currentUser.lastName}`.trim();
return fullName || this.currentUser.email;
}
return '';
}
}
@@ -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})` : '');
}
}
+2
View File
@@ -0,0 +1,2 @@
// Export user models
export * from './user.model';
+400
View File
@@ -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;
}
}
+271
View File
@@ -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[];
}
+190
View File
@@ -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);
}
}