init commit
This commit is contained in:
196
README.md
196
README.md
@@ -1,2 +1,196 @@
|
|||||||
# Angular
|
/**
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
* Created by David Valera Melendez
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
# Professional Angular Resume Builder
|
||||||
|
|
||||||
|
**Created by David Valera Melendez** | david@valera-melendez.de | Made in Germany 🇩🇪
|
||||||
|
|
||||||
|
A modern, professional resume builder application built with Angular 17, TypeScript, and Angular Material UI. This project follows the same design patterns and architecture as the Next.js version, adapted for Angular ecosystem.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Professional Design**: Clean, modern interface with Angular Material UI
|
||||||
|
- **Real-time Preview**: See your resume as you build it
|
||||||
|
- **Multiple Sections**: Personal info, experience, education, skills, languages, certifications, and projects
|
||||||
|
- **Responsive Design**: Works perfectly on desktop and mobile devices
|
||||||
|
- **Export Options**: Generate PDF versions of your resume
|
||||||
|
- **TypeScript**: Fully typed for better development experience
|
||||||
|
- **Angular Material**: Modern Material Design components and theming
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Angular 17+ with standalone components
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **UI Library**: Angular Material UI
|
||||||
|
- **Styling**: SCSS with Angular Material theming
|
||||||
|
- **Icons**: Material Icons
|
||||||
|
- **Forms**: Angular Reactive Forms
|
||||||
|
- **State Management**: Angular Services with RxJS
|
||||||
|
- **Export**: HTML to PDF conversion
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
1. **Prerequisites**: Node.js 18+ and npm 9+
|
||||||
|
|
||||||
|
2. **Install dependencies**:
|
||||||
|
```bash
|
||||||
|
# Navigate to project directory
|
||||||
|
cd Angular
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Development server**:
|
||||||
|
```bash
|
||||||
|
# Start the development server
|
||||||
|
ng serve
|
||||||
|
# Navigate to http://localhost:4200/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Available Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # Start development server
|
||||||
|
npm run build # Build for production
|
||||||
|
npm run build:prod # Build for production with optimizations
|
||||||
|
npm test # Run unit tests
|
||||||
|
npm run test:coverage # Run tests with coverage
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
npm run format # Format code with Prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Angular/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ │ ├── layout/ # Layout components (header, footer)
|
||||||
|
│ │ │ ├── resume/ # Resume-specific components
|
||||||
|
│ │ │ ├── ui/ # Generic UI components
|
||||||
|
│ │ │ └── shared/ # Shared components
|
||||||
|
│ │ ├── pages/ # Page components (route components)
|
||||||
|
│ │ ├── services/ # Angular services
|
||||||
|
│ │ ├── models/ # TypeScript interfaces and types
|
||||||
|
│ │ ├── utils/ # Utility functions
|
||||||
|
│ │ ├── shared/ # Shared modules and pipes
|
||||||
|
│ │ └── app.component.ts # Root component
|
||||||
|
│ ├── assets/ # Static assets
|
||||||
|
│ ├── styles/ # Global styles and themes
|
||||||
|
│ └── index.html # Main HTML file
|
||||||
|
├── angular.json # Angular CLI configuration
|
||||||
|
├── package.json # Dependencies and scripts
|
||||||
|
└── tsconfig.json # TypeScript configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Design System
|
||||||
|
|
||||||
|
### Angular Material Theme
|
||||||
|
|
||||||
|
- **Primary**: Professional blue theme (#1976d2) - Material Blue
|
||||||
|
- **Accent**: Complementary color for highlights
|
||||||
|
- **Warn**: Error and warning states
|
||||||
|
- **Typography**: Roboto font family (Material Design standard)
|
||||||
|
|
||||||
|
### Component Architecture
|
||||||
|
|
||||||
|
- **Material Components**: Using Angular Material's comprehensive component library
|
||||||
|
- **Responsive Layout**: CSS Grid and Flexbox with Angular Flex Layout
|
||||||
|
- **Custom Theming**: Professional color scheme with Material Design principles
|
||||||
|
- **Consistent Spacing**: Material Design spacing system
|
||||||
|
|
||||||
|
## 🎯 Usage
|
||||||
|
|
||||||
|
1. **Start the application**: `npm start`
|
||||||
|
2. **Fill in your information** using the step-by-step form
|
||||||
|
3. **Preview your resume** in real-time
|
||||||
|
4. **Export to PDF** when ready
|
||||||
|
|
||||||
|
### Resume Sections
|
||||||
|
|
||||||
|
- **Personal Information**: Contact details and professional summary
|
||||||
|
- **Work Experience**: Job history with achievements
|
||||||
|
- **Education**: Academic background
|
||||||
|
- **Skills**: Technical and soft skills with proficiency levels
|
||||||
|
- **Languages**: Language skills with proficiency
|
||||||
|
- **Certifications**: Professional certifications
|
||||||
|
- **Projects**: Notable projects and achievements
|
||||||
|
|
||||||
|
## 🔧 Development Guidelines
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
|
||||||
|
- **Angular Style Guide**: Following official Angular style guide
|
||||||
|
- **TypeScript Strict**: Strict TypeScript configuration
|
||||||
|
- **Material Design**: Consistent with Material Design principles
|
||||||
|
- **Professional Quality**: Production-ready code with proper documentation
|
||||||
|
- **Author Attribution**: David Valera Melendez signature on all files
|
||||||
|
|
||||||
|
### Architecture Principles
|
||||||
|
|
||||||
|
- **Modular Design**: Feature-based modules
|
||||||
|
- **Reactive Programming**: RxJS for state management
|
||||||
|
- **Dependency Injection**: Angular's DI system
|
||||||
|
- **Component Communication**: Input/Output properties and services
|
||||||
|
- **Form Validation**: Angular Reactive Forms with Material validators
|
||||||
|
|
||||||
|
## 🔒 Professional Features
|
||||||
|
|
||||||
|
- **Type Safety**: Full TypeScript implementation
|
||||||
|
- **Error Handling**: Comprehensive error handling and user feedback
|
||||||
|
- **Accessibility**: WCAG compliant with Material CDK
|
||||||
|
- **Performance**: OnPush change detection and lazy loading
|
||||||
|
- **Testing**: Unit tests with Jasmine and Karma
|
||||||
|
- **Documentation**: Comprehensive inline documentation
|
||||||
|
|
||||||
|
## 📱 Browser Support
|
||||||
|
|
||||||
|
- Chrome (recommended)
|
||||||
|
- Firefox
|
||||||
|
- Safari
|
||||||
|
- Edge
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
Build for production:
|
||||||
|
```bash
|
||||||
|
npm run build:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy to:
|
||||||
|
- Firebase Hosting
|
||||||
|
- Netlify
|
||||||
|
- Vercel
|
||||||
|
- GitHub Pages
|
||||||
|
- Any static hosting service
|
||||||
|
|
||||||
|
## 👨💻 Author
|
||||||
|
|
||||||
|
**David Valera Melendez**
|
||||||
|
|
||||||
|
- Email: david@valera-melendez.de
|
||||||
|
- Location: Germany 🇩🇪
|
||||||
|
- Created: August 8, 2025
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - Feel free to use this project for your professional resume needs.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
This project is designed as a professional showcase. If you'd like to contribute or suggest improvements, please reach out via email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Professional Angular Resume Builder** - Helping professionals create outstanding resumes with Material Design | Made in Germany 🇩🇪
|
||||||
|
|||||||
124
angular.json
Normal file
124
angular.json
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"david-valera-resume-builder": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
},
|
||||||
|
"@schematics/angular:application": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/david-valera-resume-builder",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kb",
|
||||||
|
"maximumError": "1mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "2kb",
|
||||||
|
"maximumError": "4kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildOptimizer": false,
|
||||||
|
"optimization": false,
|
||||||
|
"vendorChunk": true,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"namedChunks": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "david-valera-resume-builder:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "david-valera-resume-builder:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "david-valera-resume-builder:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false,
|
||||||
|
"schematicCollections": [
|
||||||
|
"@angular-eslint/schematics"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15984
package-lock.json
generated
Normal file
15984
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
86
package.json
Normal file
86
package.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"name": "david-valera-angular-resume-builder",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Professional Angular Resume Builder with Material UI - Created by David Valera Melendez",
|
||||||
|
"author": {
|
||||||
|
"name": "David Valera Melendez",
|
||||||
|
"email": "david@valera-melendez.de",
|
||||||
|
"url": "https://valera-melendez.de"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"build:prod": "ng build --configuration production",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"test:coverage": "ng test --code-coverage",
|
||||||
|
"lint": "ng lint",
|
||||||
|
"e2e": "ng e2e",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^17.0.0",
|
||||||
|
"@angular/cdk": "^17.3.10",
|
||||||
|
"@angular/common": "^17.0.0",
|
||||||
|
"@angular/compiler": "^17.0.0",
|
||||||
|
"@angular/core": "^17.0.0",
|
||||||
|
"@angular/forms": "^17.0.0",
|
||||||
|
"@angular/material": "^17.0.0",
|
||||||
|
"@angular/platform-browser": "^17.0.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^17.0.0",
|
||||||
|
"@angular/router": "^17.0.0",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^2.5.1",
|
||||||
|
"ngx-loading-bar": "^0.0.9",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^17.0.0",
|
||||||
|
"@angular-eslint/builder": "20.1.1",
|
||||||
|
"@angular-eslint/eslint-plugin": "20.1.1",
|
||||||
|
"@angular-eslint/eslint-plugin-template": "20.1.1",
|
||||||
|
"@angular-eslint/schematics": "20.1.1",
|
||||||
|
"@angular-eslint/template-parser": "20.1.1",
|
||||||
|
"@angular/cli": "^17.0.0",
|
||||||
|
"@angular/compiler-cli": "^17.0.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/node": "^18.18.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||||
|
"@typescript-eslint/parser": "8.39.0",
|
||||||
|
"eslint": "8.57.1",
|
||||||
|
"jasmine-core": "~5.1.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"typescript": "~5.2.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"angular",
|
||||||
|
"resume",
|
||||||
|
"cv",
|
||||||
|
"builder",
|
||||||
|
"material-ui",
|
||||||
|
"typescript",
|
||||||
|
"professional",
|
||||||
|
"david-valera-melendez"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/davidvalera/angular-resume-builder.git"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17.0",
|
||||||
|
"npm": ">=9.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/app.component.css
Normal file
19
src/app/app.component.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Root Application Component Styles
|
||||||
|
* Professional Angular Resume Builder - Application Root
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Root application component styles and global host element configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Root Host Element
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
14
src/app/app.component.html
Normal file
14
src/app/app.component.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!--
|
||||||
|
Root Application Template
|
||||||
|
Professional Angular Resume Builder
|
||||||
|
|
||||||
|
@author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
@created 2025-08-08
|
||||||
|
@location Made in Germany 🇩🇪
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Global Progress Bar -->
|
||||||
|
<app-global-progress-bar></app-global-progress-bar>
|
||||||
|
|
||||||
|
<!-- Main Application Content -->
|
||||||
|
<router-outlet></router-outlet>
|
||||||
84
src/app/app.component.ts
Normal file
84
src/app/app.component.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Root Application Component
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AuthService } from './services/auth.service';
|
||||||
|
import { NavigationService } from './core/services/navigation.service';
|
||||||
|
import { GlobalProgressBarComponent } from './components/ui/global-progress-bar/global-progress-bar.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root application component - simplified to handle routing only
|
||||||
|
* Layout components handle the UI structure
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterOutlet,
|
||||||
|
GlobalProgressBarComponent
|
||||||
|
],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrls: ['./app.component.css']
|
||||||
|
})
|
||||||
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
|
/**
|
||||||
|
* Application title - Professional Resume Builder by David Valera Melendez
|
||||||
|
*/
|
||||||
|
title = 'Professional Resume Builder - David Valera Melendez';
|
||||||
|
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private navigationService: NavigationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Initialize authentication state watcher for automatic redirects
|
||||||
|
this.initializeAuthenticationWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize authentication state watcher for automatic redirects
|
||||||
|
*/
|
||||||
|
private initializeAuthenticationWatcher(): void {
|
||||||
|
this.authService.isAuthenticated$
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe(isAuthenticated => {
|
||||||
|
if (!isAuthenticated && !this.isOnPublicRoute()) {
|
||||||
|
this.handleUnauthenticatedRedirect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current route is public (doesn't require authentication)
|
||||||
|
*/
|
||||||
|
private isOnPublicRoute(): boolean {
|
||||||
|
return this.navigationService.isPublicRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle redirect for unauthenticated users
|
||||||
|
*/
|
||||||
|
private async handleUnauthenticatedRedirect(): Promise<void> {
|
||||||
|
await this.navigationService.navigateToLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/app/app.routes.ts
Normal file
196
src/app/app.routes.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Application Routes Configuration
|
||||||
|
* Professional Angular Resume Builder - Enterprise Grade Routing
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { AuthGuard, GuestGuard } from './guards/auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enterprise-grade application routing configuration
|
||||||
|
* Defines all application routes with proper lazy loading, guards, and SEO-friendly titles
|
||||||
|
*/
|
||||||
|
export const routes: Routes = [
|
||||||
|
// Default route redirect
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: '/login',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Main layout routes (with navigation header)
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./layouts/main-layout/main-layout.component').then(m => m.MainLayoutComponent),
|
||||||
|
children: [
|
||||||
|
// Protected home/dashboard route (requires authentication)
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent),
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
title: 'Dashboard - Professional Resume Builder',
|
||||||
|
data: {
|
||||||
|
description: 'Your personal resume builder dashboard',
|
||||||
|
keywords: 'resume builder, dashboard, CV creator, professional resume',
|
||||||
|
breadcrumb: 'Dashboard',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Protected routes (require authentication)
|
||||||
|
{
|
||||||
|
path: 'builder',
|
||||||
|
loadComponent: () => import('./pages/resume-builder/resume-builder.component').then(m => m.ResumeBuilderComponent),
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
title: 'Resume Builder - Create Your Professional CV',
|
||||||
|
data: {
|
||||||
|
description: 'Build your professional resume with our advanced tools',
|
||||||
|
breadcrumb: 'Builder',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent),
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
title: 'Dashboard - Resume Builder',
|
||||||
|
data: {
|
||||||
|
description: 'Your personal resume builder dashboard',
|
||||||
|
breadcrumb: 'Dashboard',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'preview',
|
||||||
|
loadComponent: () => import('./pages/preview/preview.component').then(m => m.PreviewComponent),
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
title: 'Resume Preview - Professional CV Display',
|
||||||
|
data: {
|
||||||
|
description: 'Preview your professional resume before download',
|
||||||
|
breadcrumb: 'Preview',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadComponent: () => import('./pages/settings/settings.component').then(m => m.SettingsComponent),
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
title: 'Account Settings - Resume Builder',
|
||||||
|
data: {
|
||||||
|
description: 'Manage your account settings and preferences',
|
||||||
|
breadcrumb: 'Settings',
|
||||||
|
requiresAuth: true,
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Profile routes
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
redirectTo: '/settings',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Templates and showcase routes
|
||||||
|
{
|
||||||
|
path: 'templates',
|
||||||
|
loadComponent: () => import('./pages/templates/templates.component').then(m => m.TemplatesComponent),
|
||||||
|
title: 'Resume Templates - Professional Designs',
|
||||||
|
data: {
|
||||||
|
description: 'Choose from our collection of professional resume templates',
|
||||||
|
breadcrumb: 'Templates'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth layout routes (without navigation header)
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./layouts/auth-layout/auth-layout.component').then(m => m.AuthLayoutComponent),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () => import('./pages/login/login.component').then(m => m.LoginComponent),
|
||||||
|
canActivate: [GuestGuard],
|
||||||
|
title: 'Sign In - Resume Builder',
|
||||||
|
data: {
|
||||||
|
description: 'Sign in to access your resume builder dashboard',
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'two-factor-auth',
|
||||||
|
loadComponent: () => import('./components/auth/two-factor-auth/two-factor-auth.component').then(m => m.TwoFactorAuthComponent),
|
||||||
|
title: '2FA Verification - Resume Builder',
|
||||||
|
data: {
|
||||||
|
description: 'Complete two-factor authentication for device verification',
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
loadComponent: () => import('./auth/register/register.component').then(m => m.RegisterComponent),
|
||||||
|
canActivate: [GuestGuard],
|
||||||
|
title: 'Create Account - Resume Builder',
|
||||||
|
data: {
|
||||||
|
description: 'Create a new account to start building your professional resume',
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgot-password',
|
||||||
|
loadComponent: () => import('./auth/forgot-password/forgot-password.component').then(m => m.ForgotPasswordComponent),
|
||||||
|
canActivate: [GuestGuard],
|
||||||
|
title: 'Reset Password - Resume Builder',
|
||||||
|
data: {
|
||||||
|
description: 'Reset your password to regain access to your account',
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy auth redirects
|
||||||
|
{
|
||||||
|
path: 'auth/login',
|
||||||
|
redirectTo: '/login',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Error handling routes
|
||||||
|
{
|
||||||
|
path: '404',
|
||||||
|
loadComponent: () => import('./pages/error/not-found/not-found.component').then(m => m.NotFoundComponent),
|
||||||
|
title: 'Page Not Found - Resume Builder',
|
||||||
|
data: {
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '403',
|
||||||
|
loadComponent: () => import('./pages/error/forbidden/forbidden.component').then(m => m.ForbiddenComponent),
|
||||||
|
title: 'Access Denied - Resume Builder',
|
||||||
|
data: {
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '500',
|
||||||
|
loadComponent: () => import('./pages/error/server-error/server-error.component').then(m => m.ServerErrorComponent),
|
||||||
|
title: 'Server Error - Resume Builder',
|
||||||
|
data: {
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wildcard route - must be last
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: '/404'
|
||||||
|
}
|
||||||
|
];
|
||||||
247
src/app/auth/forgot-password/forgot-password.component.css
Normal file
247
src/app/auth/forgot-password/forgot-password.component.css
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Forgot Password Component Styles
|
||||||
|
* Professional Angular Resume Builder - Password Recovery System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Password recovery form with email validation and reset functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Forgot Password Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.forgot-password-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-card-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-card {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 8px 25px var(--color-shadow-medium);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 2rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header h1 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-form {
|
||||||
|
padding: 0 2rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-sent-content {
|
||||||
|
padding: 0 2rem 1rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon mat-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-sent-content h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-sent-content p {
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-sent-actions {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resend-button {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-login {
|
||||||
|
padding: 1rem 2rem 2rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material Form Field Styling */
|
||||||
|
::ng-deep .mat-mdc-form-field {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-form-field-outline {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-text-field-wrapper {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Styling */
|
||||||
|
::ng-deep .mat-mdc-form-field.mat-form-field-invalid .mat-mdc-form-field-outline-thick {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success Snackbar */
|
||||||
|
::ng-deep .success-snackbar {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Snackbar */
|
||||||
|
::ng-deep .info-snackbar {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Snackbar */
|
||||||
|
::ng-deep .error-snackbar {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.forgot-password-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-card {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header {
|
||||||
|
padding: 1.5rem 1.5rem 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-form,
|
||||||
|
.email-sent-content {
|
||||||
|
padding: 0 1.5rem 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-login {
|
||||||
|
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.forgot-password-container {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.reset-button mat-spinner {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus States */
|
||||||
|
::ng-deep .mat-mdc-form-field.mat-focused .mat-mdc-form-field-outline-thick {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation */
|
||||||
|
.forgot-password-card {
|
||||||
|
animation: slideInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Email Sent Animation */
|
||||||
|
.email-sent-content {
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/auth/forgot-password/forgot-password.component.html
Normal file
77
src/app/auth/forgot-password/forgot-password.component.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<div class="forgot-password-container">
|
||||||
|
<div class="forgot-password-card-wrapper">
|
||||||
|
<mat-card class="forgot-password-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="forgot-password-header">
|
||||||
|
<h1>Reset Your Password</h1>
|
||||||
|
<p *ngIf="!emailSent">Enter your email address and we'll send you a link to reset your password</p>
|
||||||
|
<p *ngIf="emailSent">Check your email for reset instructions</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<!-- Email Form -->
|
||||||
|
<form *ngIf="!emailSent"
|
||||||
|
[formGroup]="forgotPasswordForm"
|
||||||
|
(ngSubmit)="onForgotPassword()"
|
||||||
|
class="forgot-password-form">
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Email Address</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="email"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
autocomplete="email">
|
||||||
|
<mat-icon matSuffix>email</mat-icon>
|
||||||
|
<mat-error *ngIf="hasFieldError('email')">
|
||||||
|
{{ getFieldError('email') }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
class="reset-button"
|
||||||
|
[disabled]="isLoading || forgotPasswordForm.invalid">
|
||||||
|
<mat-spinner *ngIf="isLoading" diameter="20"></mat-spinner>
|
||||||
|
<span *ngIf="!isLoading">Send Reset Link</span>
|
||||||
|
<span *ngIf="isLoading">Sending...</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Success State -->
|
||||||
|
<div *ngIf="emailSent" class="email-sent-content">
|
||||||
|
<div class="success-icon">
|
||||||
|
<mat-icon>mark_email_read</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h3>Check Your Email</h3>
|
||||||
|
<p>We've sent password reset instructions to your email address.
|
||||||
|
If you don't receive an email within a few minutes, please check your spam folder.</p>
|
||||||
|
|
||||||
|
<div class="email-sent-actions">
|
||||||
|
<button mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
(click)="resendEmail()"
|
||||||
|
class="resend-button">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Resend Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions>
|
||||||
|
<div class="back-to-login">
|
||||||
|
<button mat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="navigateToLogin()"
|
||||||
|
class="back-button">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Back to Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
212
src/app/auth/forgot-password/forgot-password.component.ts
Normal file
212
src/app/auth/forgot-password/forgot-password.component.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Forgot Password Component
|
||||||
|
* Professional password reset form with email validation
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule, Router } from '@angular/router';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil, finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-forgot-password',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
],
|
||||||
|
templateUrl: './forgot-password.component.html',
|
||||||
|
styleUrls: ['./forgot-password.component.css']
|
||||||
|
})
|
||||||
|
export class ForgotPasswordComponent implements OnInit, OnDestroy {
|
||||||
|
forgotPasswordForm!: FormGroup;
|
||||||
|
isLoading = false;
|
||||||
|
emailSent = false;
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initializeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the forgot password form with validation
|
||||||
|
*/
|
||||||
|
private initializeForm(): void {
|
||||||
|
this.forgotPasswordForm = this.fb.group({
|
||||||
|
email: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.email,
|
||||||
|
Validators.maxLength(255)
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get form field error message
|
||||||
|
*/
|
||||||
|
getFieldError(fieldName: string): string {
|
||||||
|
const field = this.forgotPasswordForm.get(fieldName);
|
||||||
|
if (field?.hasError('required')) {
|
||||||
|
return 'Email address is required';
|
||||||
|
}
|
||||||
|
if (field?.hasError('email')) {
|
||||||
|
return 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
if (field?.hasError('maxlength')) {
|
||||||
|
return 'Email address cannot exceed 255 characters';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if form field has error
|
||||||
|
*/
|
||||||
|
hasFieldError(fieldName: string): boolean {
|
||||||
|
const field = this.forgotPasswordForm.get(fieldName);
|
||||||
|
return !!(field?.invalid && (field?.dirty || field?.touched));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle forgot password request
|
||||||
|
*/
|
||||||
|
onForgotPassword(): void {
|
||||||
|
if (this.forgotPasswordForm.invalid) {
|
||||||
|
this.markFormGroupTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
const email = this.forgotPasswordForm.value.email;
|
||||||
|
|
||||||
|
// For now, we'll simulate the API call since we don't have a backend
|
||||||
|
// In a real implementation, this would call this.authService.forgotPassword(email)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.emailSent = true;
|
||||||
|
this.showSuccessMessage(
|
||||||
|
'If an account with this email exists, you will receive password reset instructions shortly.'
|
||||||
|
);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Real implementation would be:
|
||||||
|
/*
|
||||||
|
this.authService.forgotPassword(email)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
finalize(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.emailSent = true;
|
||||||
|
this.showSuccessMessage(
|
||||||
|
'If an account with this email exists, you will receive password reset instructions shortly.'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Forgot password error:', error);
|
||||||
|
// Show generic message for security
|
||||||
|
this.showInfoMessage(
|
||||||
|
'If an account with this email exists, you will receive password reset instructions shortly.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all form fields as touched to show validation errors
|
||||||
|
*/
|
||||||
|
private markFormGroupTouched(): void {
|
||||||
|
Object.keys(this.forgotPasswordForm.controls).forEach(key => {
|
||||||
|
const control = this.forgotPasswordForm.get(key);
|
||||||
|
control?.markAsTouched();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to login page
|
||||||
|
*/
|
||||||
|
navigateToLogin(): void {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend reset email
|
||||||
|
*/
|
||||||
|
resendEmail(): void {
|
||||||
|
this.emailSent = false;
|
||||||
|
this.onForgotPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show success message to user
|
||||||
|
*/
|
||||||
|
private showSuccessMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 8000,
|
||||||
|
panelClass: ['success-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show info message to user
|
||||||
|
*/
|
||||||
|
private showInfoMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 8000,
|
||||||
|
panelClass: ['info-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error message to user
|
||||||
|
*/
|
||||||
|
private showErrorMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 8000,
|
||||||
|
panelClass: ['error-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/app/auth/register/register.component.css
Normal file
242
src/app/auth/register/register.component.css
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* User Registration Component Styles
|
||||||
|
* Professional Angular Resume Builder - User Account Registration
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description User registration form with validation, social auth, and professional styling
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Registration Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.register-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-card-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-card {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 8px 25px var(--color-shadow-medium);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 2rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header h1 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form {
|
||||||
|
padding: 0 2rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-checkbox,
|
||||||
|
.newsletter-checkbox {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
padding: 1rem 2rem 2rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
padding: 0;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material Form Field Styling */
|
||||||
|
::ng-deep .mat-mdc-form-field {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-form-field-outline {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-text-field-wrapper {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Styling */
|
||||||
|
::ng-deep .mat-mdc-form-field.mat-form-field-invalid .mat-mdc-form-field-outline-thick {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success Snackbar */
|
||||||
|
::ng-deep .success-snackbar {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Snackbar */
|
||||||
|
::ng-deep .error-snackbar {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.register-container {
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-card {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header {
|
||||||
|
padding: 1.5rem 1.5rem 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form {
|
||||||
|
padding: 0 1.5rem 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-field {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.register-container {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.register-button mat-spinner {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus States */
|
||||||
|
::ng-deep .mat-mdc-form-field.mat-focused .mat-mdc-form-field-outline-thick {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox Styling */
|
||||||
|
::ng-deep .mat-mdc-checkbox {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-checkbox .mdc-form-field {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation */
|
||||||
|
.register-card {
|
||||||
|
animation: slideInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/app/auth/register/register.component.html
Normal file
146
src/app/auth/register/register.component.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<div class="register-container">
|
||||||
|
<div class="register-card-wrapper">
|
||||||
|
<mat-card class="register-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div class="register-header">
|
||||||
|
<h1>Ihr Konto erstellen</h1>
|
||||||
|
<p>Werden Sie Teil von Tausenden von Fachkräften, die beeindruckende Lebensläufe erstellen</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="registerForm" (ngSubmit)="onRegister()" class="register-form">
|
||||||
|
<!-- Name Fields -->
|
||||||
|
<div class="name-row">
|
||||||
|
<mat-form-field appearance="outline" class="name-field">
|
||||||
|
<mat-label>Vorname</mat-label>
|
||||||
|
<input matInput
|
||||||
|
formControlName="firstName"
|
||||||
|
placeholder="Geben Sie Ihren Vornamen ein"
|
||||||
|
autocomplete="given-name">
|
||||||
|
<mat-icon matSuffix>person</mat-icon>
|
||||||
|
<mat-error *ngIf="hasFieldError('firstName')">
|
||||||
|
{{ getFieldError('firstName') }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="name-field">
|
||||||
|
<mat-label>Nachname</mat-label>
|
||||||
|
<input matInput
|
||||||
|
formControlName="lastName"
|
||||||
|
placeholder="Geben Sie Ihren Nachnamen ein"
|
||||||
|
autocomplete="family-name">
|
||||||
|
<mat-icon matSuffix>person</mat-icon>
|
||||||
|
<mat-error *ngIf="hasFieldError('lastName')">
|
||||||
|
{{ getFieldError('lastName') }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Field -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>E-Mail-Adresse</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="email"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="Geben Sie Ihre E-Mail-Adresse ein"
|
||||||
|
autocomplete="email">
|
||||||
|
<mat-icon matSuffix>email</mat-icon>
|
||||||
|
<mat-error *ngIf="hasFieldError('email')">
|
||||||
|
{{ getFieldError('email') }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Password Field -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Password</mat-label>
|
||||||
|
<input matInput
|
||||||
|
[type]="hidePassword ? 'password' : 'text'"
|
||||||
|
formControlName="password"
|
||||||
|
placeholder="Create a secure password"
|
||||||
|
autocomplete="new-password">
|
||||||
|
<button mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
(click)="hidePassword = !hidePassword"
|
||||||
|
[attr.aria-label]="'Hide password'"
|
||||||
|
[attr.aria-pressed]="hidePassword">
|
||||||
|
<mat-icon>{{ hidePassword ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-error *ngIf="hasFieldError('password')">
|
||||||
|
{{ getFieldError('password') }}
|
||||||
|
</mat-error>
|
||||||
|
<mat-hint>Must contain uppercase, lowercase, number, and special character</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Confirm Password Field -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Confirm Password</mat-label>
|
||||||
|
<input matInput
|
||||||
|
[type]="hideConfirmPassword ? 'password' : 'text'"
|
||||||
|
formControlName="confirmPassword"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
autocomplete="new-password">
|
||||||
|
<button mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
(click)="hideConfirmPassword = !hideConfirmPassword"
|
||||||
|
[attr.aria-label]="'Hide confirm password'"
|
||||||
|
[attr.aria-pressed]="hideConfirmPassword">
|
||||||
|
<mat-icon>{{ hideConfirmPassword ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-error *ngIf="hasFieldError('confirmPassword')">
|
||||||
|
{{ getFieldError('confirmPassword') }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Terms and Conditions -->
|
||||||
|
<div class="checkbox-section">
|
||||||
|
<mat-checkbox formControlName="acceptTerms" class="terms-checkbox">
|
||||||
|
I agree to the
|
||||||
|
<a href="/terms" target="_blank" class="terms-link">Terms of Service</a>
|
||||||
|
and
|
||||||
|
<a href="/privacy" target="_blank" class="terms-link">Privacy Policy</a>
|
||||||
|
</mat-checkbox>
|
||||||
|
<mat-error *ngIf="registerForm.get('acceptTerms')?.hasError('required') && registerForm.get('acceptTerms')?.touched">
|
||||||
|
You must accept the terms and conditions
|
||||||
|
</mat-error>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Newsletter Subscription -->
|
||||||
|
<div class="checkbox-section">
|
||||||
|
<mat-checkbox formControlName="newsletter" class="newsletter-checkbox">
|
||||||
|
Send me tips and updates about resume building
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<app-loading-button
|
||||||
|
text="Create Account"
|
||||||
|
loadingText="Creating Account..."
|
||||||
|
formId="register-form"
|
||||||
|
icon="person_add"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
[disabled]="registerForm.invalid"
|
||||||
|
(clicked)="onRegister()"
|
||||||
|
class="register-button">
|
||||||
|
</app-loading-button>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions>
|
||||||
|
<div class="login-link">
|
||||||
|
<p>Already have an account?
|
||||||
|
<button mat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="navigateToLogin()"
|
||||||
|
class="login-button">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
253
src/app/auth/register/register.component.ts
Normal file
253
src/app/auth/register/register.component.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Register Component
|
||||||
|
* Professional user registration form with validation
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule, Router } from '@angular/router';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil, finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { RegisterData } from '../../models/auth.model';
|
||||||
|
import { LoadingButtonComponent } from '../../components/ui/loading-button/loading-button.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
LoadingButtonComponent
|
||||||
|
],
|
||||||
|
templateUrl: './register.component.html',
|
||||||
|
styleUrls: ['./register.component.css']
|
||||||
|
})
|
||||||
|
export class RegisterComponent implements OnInit, OnDestroy {
|
||||||
|
registerForm!: FormGroup;
|
||||||
|
hidePassword = true;
|
||||||
|
hideConfirmPassword = true;
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initializeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the registration form with validation
|
||||||
|
*/
|
||||||
|
private initializeForm(): void {
|
||||||
|
this.registerForm = this.fb.group({
|
||||||
|
firstName: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(2),
|
||||||
|
Validators.maxLength(50)
|
||||||
|
]],
|
||||||
|
lastName: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(2),
|
||||||
|
Validators.maxLength(50)
|
||||||
|
]],
|
||||||
|
email: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.email,
|
||||||
|
Validators.maxLength(255)
|
||||||
|
]],
|
||||||
|
password: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(8),
|
||||||
|
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
|
||||||
|
]],
|
||||||
|
confirmPassword: ['', [
|
||||||
|
Validators.required
|
||||||
|
]],
|
||||||
|
acceptTerms: [false, [
|
||||||
|
Validators.requiredTrue
|
||||||
|
]],
|
||||||
|
newsletter: [false]
|
||||||
|
}, {
|
||||||
|
validators: this.passwordMatchValidator
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom validator to check if passwords match
|
||||||
|
*/
|
||||||
|
private passwordMatchValidator(form: FormGroup) {
|
||||||
|
const password = form.get('password');
|
||||||
|
const confirmPassword = form.get('confirmPassword');
|
||||||
|
|
||||||
|
if (password && confirmPassword && password.value !== confirmPassword.value) {
|
||||||
|
confirmPassword.setErrors({ passwordMismatch: true });
|
||||||
|
return { passwordMismatch: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmPassword?.hasError('passwordMismatch')) {
|
||||||
|
delete confirmPassword.errors?.['passwordMismatch'];
|
||||||
|
confirmPassword.updateValueAndValidity();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get form field error message
|
||||||
|
*/
|
||||||
|
getFieldError(fieldName: string): string {
|
||||||
|
const field = this.registerForm.get(fieldName);
|
||||||
|
if (field?.hasError('required')) {
|
||||||
|
return `${this.getFieldLabel(fieldName)} is required`;
|
||||||
|
}
|
||||||
|
if (field?.hasError('email')) {
|
||||||
|
return 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
if (field?.hasError('minlength')) {
|
||||||
|
const minLength = field.errors?.['minlength']?.requiredLength;
|
||||||
|
return `${this.getFieldLabel(fieldName)} must be at least ${minLength} characters`;
|
||||||
|
}
|
||||||
|
if (field?.hasError('maxlength')) {
|
||||||
|
const maxLength = field.errors?.['maxlength']?.requiredLength;
|
||||||
|
return `${this.getFieldLabel(fieldName)} cannot exceed ${maxLength} characters`;
|
||||||
|
}
|
||||||
|
if (field?.hasError('pattern') && fieldName === 'password') {
|
||||||
|
return 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character';
|
||||||
|
}
|
||||||
|
if (field?.hasError('passwordMismatch')) {
|
||||||
|
return 'Passwords do not match';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly field label
|
||||||
|
*/
|
||||||
|
private getFieldLabel(fieldName: string): string {
|
||||||
|
const labels: { [key: string]: string } = {
|
||||||
|
firstName: 'First name',
|
||||||
|
lastName: 'Last name',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
confirmPassword: 'Confirm password'
|
||||||
|
};
|
||||||
|
return labels[fieldName] || fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if form field has error
|
||||||
|
*/
|
||||||
|
hasFieldError(fieldName: string): boolean {
|
||||||
|
const field = this.registerForm.get(fieldName);
|
||||||
|
return !!(field?.invalid && (field?.dirty || field?.touched));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle user registration
|
||||||
|
*/
|
||||||
|
onRegister(): void {
|
||||||
|
if (this.registerForm.invalid) {
|
||||||
|
this.markFormGroupTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formValue = this.registerForm.value;
|
||||||
|
|
||||||
|
const registerData: RegisterData = {
|
||||||
|
firstName: formValue.firstName,
|
||||||
|
lastName: formValue.lastName,
|
||||||
|
email: formValue.email,
|
||||||
|
password: formValue.password,
|
||||||
|
confirmPassword: formValue.confirmPassword,
|
||||||
|
acceptTerms: formValue.acceptTerms
|
||||||
|
};
|
||||||
|
|
||||||
|
this.authService.register(registerData)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.showSuccessMessage('Registration successful! Welcome to Resume Builder.');
|
||||||
|
this.router.navigate(['/builder']);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
this.showErrorMessage(
|
||||||
|
error?.error?.message || 'Registration failed. Please try again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all form fields as touched to show validation errors
|
||||||
|
*/
|
||||||
|
private markFormGroupTouched(): void {
|
||||||
|
Object.keys(this.registerForm.controls).forEach(key => {
|
||||||
|
const control = this.registerForm.get(key);
|
||||||
|
control?.markAsTouched();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to login page
|
||||||
|
*/
|
||||||
|
navigateToLogin(): void {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show success message to user
|
||||||
|
*/
|
||||||
|
private showSuccessMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['success-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error message to user
|
||||||
|
*/
|
||||||
|
private showErrorMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 8000,
|
||||||
|
panelClass: ['error-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
/**
|
||||||
|
* Two-Factor Authentication Component Styles
|
||||||
|
* Professional Angular Resume Builder - Device Registration 2FA
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Page Layout (for standalone page)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.two-factor-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.two-factor-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Header Section
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.two-factor-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Device Information Card
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.device-info-card {
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Step Content
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security Message */
|
||||||
|
.security-message {
|
||||||
|
background: var(--gradient-warning-light);
|
||||||
|
border: 1px solid var(--color-warning-border-light);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--color-warning);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verification Info */
|
||||||
|
.verification-info {
|
||||||
|
background: var(--gradient-success-light);
|
||||||
|
border: 1px solid var(--color-success-border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--color-success);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown Timer */
|
||||||
|
.countdown-timer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Demo Information Card
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.demo-info-card {
|
||||||
|
background: var(--gradient-info-light);
|
||||||
|
border: 1px solid var(--color-info-border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-content p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-content strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verification Code Display */
|
||||||
|
.verification-code-display {
|
||||||
|
text-align: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-code {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
border: 2px solid var(--color-primary-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-demo-button {
|
||||||
|
font-size: 12px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Forms & Buttons
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.verification-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons,
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-button,
|
||||||
|
.verify-button {
|
||||||
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-button {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
box-shadow: 0 4px 12px var(--color-purple-glow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-button {
|
||||||
|
background: var(--gradient-success);
|
||||||
|
box-shadow: 0 4px 12px var(--color-green-glow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button,
|
||||||
|
.cancel-button {
|
||||||
|
height: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-spinner {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Environment Information
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.environment-info {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-details {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-chips {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-chip {
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Security Features
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.security-features {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Responsive Design
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.two-factor-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-features {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item span {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.action-buttons,
|
||||||
|
.form-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-button,
|
||||||
|
.verify-button {
|
||||||
|
height: 44px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-message,
|
||||||
|
.verification-info,
|
||||||
|
.demo-info-card {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon,
|
||||||
|
.check-icon,
|
||||||
|
.demo-icon {
|
||||||
|
margin-top: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!--
|
||||||
|
Two-Factor Authent <div class="device-detail">
|
||||||
|
<span class="detail-label">User:</span>
|
||||||
|
<span class="detail-value">{{ data.userName || 'Unknown User' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-detail">
|
||||||
|
<span class="detail-label">Email:</span>
|
||||||
|
<span class="detail-value">{{ data.userEmail }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-detail" *ngIf="data.reason">
|
||||||
|
<span class="detail-label">Reason:</span>
|
||||||
|
<span class="detail-value">{{ data.reason }}</span>
|
||||||
|
</div>og Template
|
||||||
|
Professional Angular Resume Builder - Device Registration 2FA
|
||||||
|
|
||||||
|
@author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
@created 2025-08-08
|
||||||
|
@location Made in Germany 🇩🇪
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Two-Factor Authentication Page -->
|
||||||
|
<div class="two-factor-page">
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="two-factor-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="two-factor-header">
|
||||||
|
<div class="header-icon">
|
||||||
|
<mat-icon class="security-icon">security</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h2 class="header-title">
|
||||||
|
{{ data.flowType === 'device_registration' ? 'New Device Detected' : 'Two-Factor Authentication' }}
|
||||||
|
</h2>
|
||||||
|
<p class="header-subtitle">
|
||||||
|
{{ data.flowType === 'device_registration' ? 'Secure your account with device registration' : 'Complete authentication to continue' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Information -->
|
||||||
|
<div class="device-info-card">
|
||||||
|
<div class="device-info-header">
|
||||||
|
<mat-icon class="device-icon">devices</mat-icon>
|
||||||
|
<span class="device-title">Device Information</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-details">
|
||||||
|
<div class="device-detail">
|
||||||
|
<span class="detail-label">User:</span>
|
||||||
|
<span class="detail-value">{{ data.userName || 'Unknown User' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-detail">
|
||||||
|
<span class="detail-label">Email:</span>
|
||||||
|
<span class="detail-value">{{ data.userEmail }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-detail" *ngIf="data.reason">
|
||||||
|
<span class="detail-label">Reason:</span>
|
||||||
|
<span class="detail-value">{{ data.reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Request 2FA Code -->
|
||||||
|
<div *ngIf="step === 'request'" class="step-content">
|
||||||
|
<!-- New Device Registration Flow -->
|
||||||
|
<div *ngIf="data.flowType === 'device_registration'" class="security-message">
|
||||||
|
<mat-icon class="warning-icon">warning</mat-icon>
|
||||||
|
<div class="message-content">
|
||||||
|
<h3>New Device Detected</h3>
|
||||||
|
<p>You are logging in from a new browser or device. For security reasons, we need to verify and register this device.</p>
|
||||||
|
<p>Click the button below to request a two-factor authentication code.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Device 2FA Code Required -->
|
||||||
|
<div *ngIf="data.flowType === 'code_required'" class="security-message">
|
||||||
|
<mat-icon class="warning-icon">security</mat-icon>
|
||||||
|
<div class="message-content">
|
||||||
|
<h3>Two-Factor Authentication Required</h3>
|
||||||
|
<p>For security reasons, you need to complete two-factor authentication to continue.</p>
|
||||||
|
<p>Click the button below to request your authentication code.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
class="request-button"
|
||||||
|
[disabled]="isInitiating"
|
||||||
|
(click)="requestTwoFactorCode()"
|
||||||
|
>
|
||||||
|
<mat-spinner
|
||||||
|
*ngIf="isInitiating"
|
||||||
|
diameter="20"
|
||||||
|
class="button-spinner">
|
||||||
|
</mat-spinner>
|
||||||
|
<mat-icon *ngIf="!isInitiating">send</mat-icon>
|
||||||
|
<span *ngIf="!isInitiating">Request Verification Code</span>
|
||||||
|
<span *ngIf="isInitiating">Requesting Code...</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
color="warn"
|
||||||
|
class="cancel-button"
|
||||||
|
[disabled]="isInitiating"
|
||||||
|
(click)="navigateToLogin()"
|
||||||
|
>
|
||||||
|
Cancel Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Verify 2FA Code -->
|
||||||
|
<div *ngIf="step === 'verify' && twoFactorResponse" class="step-content">
|
||||||
|
<div class="verification-info">
|
||||||
|
<mat-icon class="check-icon">mark_email_read</mat-icon>
|
||||||
|
<div class="info-content">
|
||||||
|
<p>{{ twoFactorResponse.message }}</p>
|
||||||
|
|
||||||
|
<!-- Countdown Timer -->
|
||||||
|
<div *ngIf="countdown > 0" class="countdown-timer">
|
||||||
|
<mat-icon class="timer-icon">timer</mat-icon>
|
||||||
|
<span>Code expires in: {{ getFormattedCountdown() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Demo Information (if available) -->
|
||||||
|
<div *ngIf="isDemoMode() && twoFactorResponse.verificationCode" class="demo-info-card">
|
||||||
|
<div class="demo-content">
|
||||||
|
<div class="verification-code-display">
|
||||||
|
<span class="verification-label">Verification code:</span>
|
||||||
|
<div class="verification-code">{{ twoFactorResponse.verificationCode }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
color="accent"
|
||||||
|
class="fill-demo-button"
|
||||||
|
(click)="fillDemoCode()"
|
||||||
|
>
|
||||||
|
<mat-icon>auto_fix_high</mat-icon>
|
||||||
|
Fill Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verification Form -->
|
||||||
|
<form [formGroup]="twoFactorForm" (ngSubmit)="submitVerificationCode()" class="verification-form">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>6-Digit Verification Code</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
formControlName="verificationCode"
|
||||||
|
placeholder="000000"
|
||||||
|
maxlength="6"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
[class.error]="hasFieldError('verificationCode')"
|
||||||
|
>
|
||||||
|
<mat-icon matSuffix>lock</mat-icon>
|
||||||
|
<mat-error *ngIf="hasFieldError('verificationCode')">
|
||||||
|
{{ getFieldError('verificationCode') }}
|
||||||
|
</mat-error>
|
||||||
|
<mat-hint>Enter the 6-digit code you received</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
class="verify-button"
|
||||||
|
[disabled]="!canSubmitVerification"
|
||||||
|
>
|
||||||
|
<mat-spinner
|
||||||
|
*ngIf="isVerifying"
|
||||||
|
diameter="20"
|
||||||
|
class="button-spinner">
|
||||||
|
</mat-spinner>
|
||||||
|
<mat-icon *ngIf="!isVerifying">verified_user</mat-icon>
|
||||||
|
<span *ngIf="!isVerifying">Verify & Register Device</span>
|
||||||
|
<span *ngIf="isVerifying">Verifying...</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
class="back-button"
|
||||||
|
[disabled]="isVerifying"
|
||||||
|
(click)="step = 'request'"
|
||||||
|
>
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
color="warn"
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
[disabled]="isVerifying"
|
||||||
|
(click)="navigateToLogin()"
|
||||||
|
>
|
||||||
|
Cancel Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Features Info -->
|
||||||
|
<div class="security-features">
|
||||||
|
<div class="feature-item">
|
||||||
|
<mat-icon class="feature-icon">shield</mat-icon>
|
||||||
|
<span>End-to-end encryption</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<mat-icon class="feature-icon">fingerprint</mat-icon>
|
||||||
|
<span>Device fingerprinting</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<mat-icon class="feature-icon">verified</mat-icon>
|
||||||
|
<span>GDPR compliant</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* Two-Factor Authentication Component
|
||||||
|
* Professional Angular Resume Builder - Device Registration 2FA
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
|
import { Subject, timer } from 'rxjs';
|
||||||
|
import { takeUntil, finalize, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Angular Material Modules
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
|
||||||
|
// Services and Models
|
||||||
|
import { AuthService } from '../../../services/auth.service';
|
||||||
|
import { TwoFactorInitiationResponse } from '../../../models/auth.model';
|
||||||
|
|
||||||
|
interface TwoFactorPageData {
|
||||||
|
userId: number;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
reason?: string;
|
||||||
|
riskScore?: number;
|
||||||
|
flowType?: 'device_registration' | 'code_required';
|
||||||
|
deviceInfo?: {
|
||||||
|
isTrusted: boolean;
|
||||||
|
deviceName?: string;
|
||||||
|
lastUsed?: Date;
|
||||||
|
};
|
||||||
|
tempToken?: string | null;
|
||||||
|
timestamp?: number; // For data expiration checking
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-Factor Authentication Page Component
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-two-factor-auth',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatChipsModule
|
||||||
|
],
|
||||||
|
templateUrl: './two-factor-auth.component.html',
|
||||||
|
styleUrls: ['./two-factor-auth.component.css']
|
||||||
|
})
|
||||||
|
export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||||
|
// Form Management
|
||||||
|
twoFactorForm!: FormGroup;
|
||||||
|
|
||||||
|
// State Management
|
||||||
|
isInitiating = false;
|
||||||
|
isVerifying = false;
|
||||||
|
step: 'request' | 'verify' = 'request';
|
||||||
|
twoFactorResponse: TwoFactorInitiationResponse | null = null;
|
||||||
|
countdown = 0;
|
||||||
|
|
||||||
|
// Computed Properties
|
||||||
|
get isVerificationCodeValid(): boolean {
|
||||||
|
const codeControl = this.twoFactorForm?.get('verificationCode');
|
||||||
|
return !!(codeControl && codeControl.valid && codeControl.value && codeControl.value.length === 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSubmitVerification(): boolean {
|
||||||
|
return this.isVerificationCodeValid && !this.isVerifying && !!this.twoFactorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page Data
|
||||||
|
data: TwoFactorPageData = {
|
||||||
|
userId: 0,
|
||||||
|
userEmail: '',
|
||||||
|
userName: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
private countdownTimer$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private authService: AuthService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private router: Router,
|
||||||
|
private route: ActivatedRoute
|
||||||
|
) {
|
||||||
|
this.initializeForms();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Get data from sessionStorage (more secure than URL parameters)
|
||||||
|
const storedData = sessionStorage.getItem('twoFactorData');
|
||||||
|
|
||||||
|
if (storedData) {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(storedData);
|
||||||
|
|
||||||
|
// Check if data is not too old (10 minutes max)
|
||||||
|
const maxAge = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||||
|
if (Date.now() - parsedData.timestamp > maxAge) {
|
||||||
|
sessionStorage.removeItem('twoFactorData');
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = parsedData;
|
||||||
|
} catch (error) {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: try to get from query params (backward compatibility)
|
||||||
|
this.route.queryParams.subscribe(params => {
|
||||||
|
if (params['userId'] || params['email']) {
|
||||||
|
this.data = {
|
||||||
|
userId: +params['userId'] || 0,
|
||||||
|
userEmail: params['email'] || '',
|
||||||
|
userName: params['name'] || '',
|
||||||
|
reason: params['reason'] || 'New device detected',
|
||||||
|
riskScore: +params['riskScore'] || 0,
|
||||||
|
flowType: params['flowType'] || 'device_registration',
|
||||||
|
tempToken: params['tempToken'] || null,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// No data found, redirect to login
|
||||||
|
this.snackBar.open('Invalid 2FA session. Please login again.', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['error-snackbar']
|
||||||
|
});
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid data, redirect back to login
|
||||||
|
if (!this.data.userId || !this.data.userEmail) {
|
||||||
|
this.snackBar.open('Invalid 2FA session. Please login again.', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['error-snackbar']
|
||||||
|
});
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
this.countdownTimer$.next();
|
||||||
|
this.countdownTimer$.complete();
|
||||||
|
|
||||||
|
// Clean up sensitive data when component is destroyed
|
||||||
|
sessionStorage.removeItem('twoFactorData');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize reactive forms
|
||||||
|
*/
|
||||||
|
private initializeForms(): void {
|
||||||
|
this.twoFactorForm = this.formBuilder.group({
|
||||||
|
verificationCode: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.pattern(/^\d{6}$/)
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request 2FA code from backend
|
||||||
|
*/
|
||||||
|
requestTwoFactorCode(): void {
|
||||||
|
if (this.isInitiating) return;
|
||||||
|
|
||||||
|
this.isInitiating = true;
|
||||||
|
|
||||||
|
this.authService.initiateTwoFactor(this.data.userId)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
finalize(() => {
|
||||||
|
this.isInitiating = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.twoFactorResponse = response;
|
||||||
|
this.step = 'verify';
|
||||||
|
this.startCountdown(response.durationMinutes * 60);
|
||||||
|
|
||||||
|
this.snackBar.open(response.message, 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['info-snackbar']
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.snackBar.open('Failed to request verification code. Please try again.', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['error-snackbar']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit verification code
|
||||||
|
*/
|
||||||
|
submitVerificationCode(): void {
|
||||||
|
// Enhanced validation
|
||||||
|
if (!this.canSubmitVerification) {
|
||||||
|
this.twoFactorForm.markAllAsTouched();
|
||||||
|
|
||||||
|
// Show specific error message
|
||||||
|
const codeControl = this.twoFactorForm.get('verificationCode');
|
||||||
|
if (!codeControl?.value) {
|
||||||
|
this.snackBar.open('Please enter the verification code', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['error-snackbar']
|
||||||
|
});
|
||||||
|
} else if (codeControl.value.length !== 6) {
|
||||||
|
this.snackBar.open('Verification code must be 6 digits', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['error-snackbar']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isVerifying = true;
|
||||||
|
const code = this.twoFactorForm.get('verificationCode')?.value;
|
||||||
|
|
||||||
|
this.authService.verifyTwoFactor(code, this.twoFactorResponse!.verificationId, this.data.userId, this.data.tempToken || undefined)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
finalize(() => {
|
||||||
|
this.isVerifying = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success) {
|
||||||
|
// Clear sensitive data from sessionStorage on success
|
||||||
|
sessionStorage.removeItem('twoFactorData');
|
||||||
|
|
||||||
|
if (this.data.flowType === 'device_registration') {
|
||||||
|
this.handleDeviceRegistrationSuccess(response);
|
||||||
|
} else if (this.data.flowType === 'code_required' && this.data.tempToken) {
|
||||||
|
this.handleLoginCompletionSuccess(response);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.handleVerificationError(response.message || 'Verification failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.handleVerificationError(error.error?.message || 'Verification failed. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful device registration
|
||||||
|
*/
|
||||||
|
private handleDeviceRegistrationSuccess(response: any): void {
|
||||||
|
this.snackBar.open('Device registered successfully! You can now login.', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['success-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate back to login after successful device registration
|
||||||
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
twoFactorComplete: 'true',
|
||||||
|
email: this.data.userEmail
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful login completion
|
||||||
|
*/
|
||||||
|
private handleLoginCompletionSuccess(response: any): void {
|
||||||
|
// Check if authentication was completed by backend
|
||||||
|
if (response.authCompleted && response.accessToken) {
|
||||||
|
// Store the access token using the correct key that AuthService expects
|
||||||
|
localStorage.setItem('david_auth_access_token', response.accessToken);
|
||||||
|
|
||||||
|
// Store user data if available using the correct key
|
||||||
|
if (response.user) {
|
||||||
|
const userData = {
|
||||||
|
id: response.user.id,
|
||||||
|
email: response.user.email,
|
||||||
|
firstName: response.user.firstName || '',
|
||||||
|
lastName: response.user.lastName || ''
|
||||||
|
};
|
||||||
|
localStorage.setItem('david_auth_user_data', JSON.stringify(userData));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snackBar.open('Authentication successful! Redirecting to dashboard...', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['success-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
this.authService.refreshAuthState();
|
||||||
|
|
||||||
|
// Navigate to dashboard
|
||||||
|
setTimeout(() => {
|
||||||
|
this.router.navigate(['/dashboard']);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// Authentication not completed - redirect to login
|
||||||
|
this.snackBar.open('2FA verification successful. Please complete login.', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['success-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
twoFactorVerified: 'true',
|
||||||
|
email: this.data.userEmail
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle verification error
|
||||||
|
*/
|
||||||
|
private handleVerificationError(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 4000,
|
||||||
|
panelClass: ['error-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the form to let user try again
|
||||||
|
this.twoFactorForm.get('verificationCode')?.setValue('');
|
||||||
|
this.twoFactorForm.get('verificationCode')?.markAsUntouched();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-fill demo code if available
|
||||||
|
*/
|
||||||
|
fillDemoCode(): void {
|
||||||
|
if (this.twoFactorResponse?.verificationCode) {
|
||||||
|
this.twoFactorForm.patchValue({
|
||||||
|
verificationCode: this.twoFactorResponse.verificationCode
|
||||||
|
});
|
||||||
|
|
||||||
|
this.snackBar.open('Demo verification code filled', 'Close', {
|
||||||
|
duration: 2000,
|
||||||
|
panelClass: ['info-snackbar']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start countdown timer for code expiration
|
||||||
|
*/
|
||||||
|
private startCountdown(seconds: number): void {
|
||||||
|
this.countdown = seconds;
|
||||||
|
|
||||||
|
timer(0, 1000)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.countdownTimer$),
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
tap(() => {
|
||||||
|
this.countdown--;
|
||||||
|
if (this.countdown <= 0) {
|
||||||
|
this.countdownTimer$.next();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format countdown time as MM:SS
|
||||||
|
*/
|
||||||
|
getFormattedCountdown(): string {
|
||||||
|
const minutes = Math.floor(this.countdown / 60);
|
||||||
|
const seconds = this.countdown % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate back to login
|
||||||
|
*/
|
||||||
|
navigateToLogin(): void {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field error message
|
||||||
|
*/
|
||||||
|
getFieldError(fieldName: string): string {
|
||||||
|
const field = this.twoFactorForm.get(fieldName);
|
||||||
|
if (!field || !field.errors || !field.touched) return '';
|
||||||
|
|
||||||
|
const errors = field.errors;
|
||||||
|
|
||||||
|
if (errors['required']) return 'Verification code is required';
|
||||||
|
if (errors['pattern']) return 'Please enter a 6-digit code';
|
||||||
|
|
||||||
|
return 'Invalid input';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if field has error
|
||||||
|
*/
|
||||||
|
hasFieldError(fieldName: string): boolean {
|
||||||
|
const field = this.twoFactorForm.get(fieldName);
|
||||||
|
return !!(field && field.invalid && field.touched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if demo mode is enabled
|
||||||
|
*/
|
||||||
|
isDemoMode(): boolean {
|
||||||
|
return this.twoFactorResponse?.environmentInfo.demoModeEnabled || false;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/components/index.ts
Normal file
11
src/app/components/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Components Export Index
|
||||||
|
* Professional Angular Resume Builder - Component Exports
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Auth Components
|
||||||
|
export { TwoFactorAuthComponent } from './auth/two-factor-auth/two-factor-auth.component';
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Global Progress Bar Component Styles
|
||||||
|
* Professional Angular Resume Builder - GitHub-style Progress Bar
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
.global-progress-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--gradient-progress);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 0%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out, width 0.3s ease-out;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-progress-bar.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GitHub-style alternative */
|
||||||
|
.global-progress-bar::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-surface-overlay-medium) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: loading-shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional blue theme */
|
||||||
|
.global-progress-bar {
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--color-primary),
|
||||||
|
var(--color-primary),
|
||||||
|
var(--color-blue-400),
|
||||||
|
var(--color-primary),
|
||||||
|
var(--color-primary)
|
||||||
|
);
|
||||||
|
background-size: 300% 100%;
|
||||||
|
box-shadow: 0 0 10px var(--color-blue-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.global-progress-bar {
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--color-blue-300),
|
||||||
|
var(--color-blue-200),
|
||||||
|
var(--color-blue-100),
|
||||||
|
var(--color-blue-200),
|
||||||
|
var(--color-blue-300)
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 10px var(--color-blue-200-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.global-progress-bar {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!--
|
||||||
|
Global Progress Bar Component Template
|
||||||
|
Professional Angular Resume Builder - GitHub-style Progress Bar
|
||||||
|
|
||||||
|
@author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
@created 2025-08-08
|
||||||
|
@location Made in Germany 🇩🇪
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="global-progress-bar"
|
||||||
|
[class.active]="isLoading"
|
||||||
|
[style.width.%]="progress">
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Global Progress Bar Component
|
||||||
|
* Professional Angular Resume Builder - GitHub-style Progress Bar
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
import { LoadingService } from '../../../services/loading.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-global-progress-bar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './global-progress-bar.component.html',
|
||||||
|
styleUrls: ['./global-progress-bar.component.css']
|
||||||
|
})
|
||||||
|
export class GlobalProgressBarComponent implements OnInit, OnDestroy {
|
||||||
|
isLoading = false;
|
||||||
|
progress = 0;
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(private loadingService: LoadingService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Subscribe to global loading state
|
||||||
|
this.loadingService.globalLoading$
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe(loading => {
|
||||||
|
this.isLoading = loading;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to progress updates
|
||||||
|
this.loadingService.progress$
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe(progress => {
|
||||||
|
this.progress = progress;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Loading Button Component Styles
|
||||||
|
* Professional Angular Resume Builder - Enterprise Button with Spinner
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
.button-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
min-height: 24px;
|
||||||
|
transition: var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-content.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-text {
|
||||||
|
transition: var(--transition-slow);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-text.loading-text {
|
||||||
|
margin-left: 32px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-margin {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional button styles */
|
||||||
|
button {
|
||||||
|
min-width: 120px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not([disabled]):hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px var(--color-shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ripple effect */
|
||||||
|
button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface-overlay-light);
|
||||||
|
transition: width 0.6s, height 0.6s, top 0.6s, left 0.6s;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size variations */
|
||||||
|
.small {
|
||||||
|
min-width: 80px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
min-width: 160px;
|
||||||
|
height: 52px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color themes */
|
||||||
|
.success {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading animation */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
animation: pulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
button {
|
||||||
|
background-color: var(--color-background-darker);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!--
|
||||||
|
Loading Button Component Template
|
||||||
|
Professional Angular Resume Builder - Enterprise Button with Spinner
|
||||||
|
|
||||||
|
@author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
@created 2025-08-08
|
||||||
|
@location Made in Germany 🇩🇪
|
||||||
|
-->
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
[color]="color"
|
||||||
|
[disabled]="isLoading || disabled"
|
||||||
|
[class]="buttonClass"
|
||||||
|
(click)="onButtonClick()"
|
||||||
|
type="submit">
|
||||||
|
|
||||||
|
<div class="button-content" [class.loading]="isLoading">
|
||||||
|
<!-- Loading Spinner -->
|
||||||
|
<div class="spinner-container" *ngIf="isLoading">
|
||||||
|
<mat-spinner [diameter]="spinnerSize" [color]="spinnerColor"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Button Icon -->
|
||||||
|
<mat-icon *ngIf="icon && !isLoading" [class.icon-margin]="text">
|
||||||
|
{{ icon }}
|
||||||
|
</mat-icon>
|
||||||
|
|
||||||
|
<!-- Button Text -->
|
||||||
|
<span class="button-text" [class.loading-text]="isLoading">
|
||||||
|
{{ isLoading ? loadingText : text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Loading Button Component
|
||||||
|
* Professional Angular Resume Builder - Enterprise Button with Spinner
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
import { LoadingService } from '../../../services/loading.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-loading-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule
|
||||||
|
],
|
||||||
|
templateUrl: './loading-button.component.html',
|
||||||
|
styleUrls: ['./loading-button.component.css']
|
||||||
|
})
|
||||||
|
export class LoadingButtonComponent implements OnInit, OnDestroy {
|
||||||
|
@Input() text: string = 'Submit';
|
||||||
|
@Input() loadingText: string = 'Processing...';
|
||||||
|
@Input() formId: string = 'default';
|
||||||
|
@Input() icon: string = '';
|
||||||
|
@Input() color: 'primary' | 'accent' | 'warn' = 'primary';
|
||||||
|
@Input() disabled: boolean = false;
|
||||||
|
@Input() size: 'small' | 'medium' | 'large' = 'medium';
|
||||||
|
@Input() spinnerSize: number = 20;
|
||||||
|
@Input() spinnerColor: 'primary' | 'accent' | 'warn' = 'primary';
|
||||||
|
|
||||||
|
@Output() clicked = new EventEmitter<void>();
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(private loadingService: LoadingService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Subscribe to form-specific loading state
|
||||||
|
this.loadingService.isFormLoading(this.formId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((loading: boolean) => {
|
||||||
|
this.isLoading = loading;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
get buttonClass(): string {
|
||||||
|
const classes = [this.size];
|
||||||
|
return classes.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
onButtonClick(): void {
|
||||||
|
if (!this.isLoading && !this.disabled) {
|
||||||
|
this.clicked.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/app/core/routing/app-routing.module.ts
Normal file
179
src/app/core/routing/app-routing.module.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Main Application Routing Module
|
||||||
|
* Enterprise-grade routing configuration with guards and proper error handling
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes, PreloadAllModules } from '@angular/router';
|
||||||
|
import { AuthGuard, GuestGuard } from '../../guards/auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application routes with proper guards and data
|
||||||
|
* Following enterprise patterns with clear separation of concerns
|
||||||
|
*/
|
||||||
|
const routes: Routes = [
|
||||||
|
// Default redirect
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: '/home',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
loadComponent: () => import('../../pages/home/home.component').then(c => c.HomeComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Home - Professional Resume Builder',
|
||||||
|
description: 'Create professional resumes with our enterprise-grade builder'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Authentication routes (only accessible when not logged in)
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
canActivate: [GuestGuard],
|
||||||
|
loadComponent: () => import('../../pages/login/login.component').then(c => c.LoginComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Login - Resume Builder',
|
||||||
|
description: 'Sign in to access your resume builder dashboard'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'two-factor-auth',
|
||||||
|
loadComponent: () => import('../../components/auth/two-factor-auth/two-factor-auth.component').then(c => c.TwoFactorAuthComponent),
|
||||||
|
data: {
|
||||||
|
title: '2FA Verification - Resume Builder',
|
||||||
|
description: 'Complete two-factor authentication for device verification'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'test-2fa',
|
||||||
|
loadComponent: () => import('../../components/auth/two-factor-auth/two-factor-auth.component').then(c => c.TwoFactorAuthComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Test 2FA - Resume Builder'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
canActivate: [GuestGuard],
|
||||||
|
loadComponent: () => import('../../auth/register/register.component').then(c => c.RegisterComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Register - Resume Builder'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgot-password',
|
||||||
|
canActivate: [GuestGuard],
|
||||||
|
loadComponent: () => import('../../auth/forgot-password/forgot-password.component').then(c => c.ForgotPasswordComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Forgot Password - Resume Builder'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Protected routes (require authentication)
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
redirectTo: '/builder',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'builder',
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
loadComponent: () => import('../../pages/resume-builder/resume-builder.component').then(c => c.ResumeBuilderComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Resume Builder',
|
||||||
|
breadcrumb: 'Builder'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'templates',
|
||||||
|
loadComponent: () => import('../../pages/templates/templates.component').then(c => c.TemplatesComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Resume Templates',
|
||||||
|
breadcrumb: 'Templates'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
loadComponent: () => import('../../pages/settings/settings.component').then(c => c.SettingsComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Account Settings',
|
||||||
|
breadcrumb: 'Settings'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
redirectTo: '/settings',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Preview routes (can be public for sharing)
|
||||||
|
{
|
||||||
|
path: 'preview',
|
||||||
|
loadComponent: () => import('../../pages/preview/preview.component').then(c => c.PreviewComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Resume Preview'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'preview/:id',
|
||||||
|
loadComponent: () => import('../../pages/preview/preview.component').then(c => c.PreviewComponent),
|
||||||
|
data: {
|
||||||
|
title: 'Resume Preview'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Error pages
|
||||||
|
{
|
||||||
|
path: '404',
|
||||||
|
loadComponent: () => import('../../pages/error/not-found/not-found.component').then(c => c.NotFoundComponent),
|
||||||
|
data: { title: 'Page Not Found' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '403',
|
||||||
|
loadComponent: () => import('../../pages/error/forbidden/forbidden.component').then(c => c.ForbiddenComponent),
|
||||||
|
data: { title: 'Access Denied' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '500',
|
||||||
|
loadComponent: () => import('../../pages/error/server-error/server-error.component').then(c => c.ServerErrorComponent),
|
||||||
|
data: { title: 'Server Error' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wildcard route - must be last
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: '/404'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forRoot(routes, {
|
||||||
|
// Enterprise routing configuration
|
||||||
|
enableTracing: false, // Set to true for debugging in development
|
||||||
|
preloadingStrategy: PreloadAllModules,
|
||||||
|
initialNavigation: 'enabledBlocking',
|
||||||
|
scrollPositionRestoration: 'top',
|
||||||
|
anchorScrolling: 'enabled',
|
||||||
|
scrollOffset: [0, 64], // Offset for fixed header
|
||||||
|
errorHandler: (error) => {
|
||||||
|
console.error('Router error:', error);
|
||||||
|
},
|
||||||
|
onSameUrlNavigation: 'reload',
|
||||||
|
paramsInheritanceStrategy: 'emptyOnly',
|
||||||
|
urlUpdateStrategy: 'deferred',
|
||||||
|
canceledNavigationResolution: 'replace'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class AppRoutingModule { }
|
||||||
282
src/app/core/services/navigation.service.ts
Normal file
282
src/app/core/services/navigation.service.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Professional Navigation Service
|
||||||
|
* Enterprise-grade navigation handling with error management and user feedback
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Router, NavigationEnd, NavigationError, NavigationCancel } from '@angular/router';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { Observable, BehaviorSubject } from 'rxjs';
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface NavigationState {
|
||||||
|
isNavigating: boolean;
|
||||||
|
currentUrl: string;
|
||||||
|
previousUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class NavigationService {
|
||||||
|
private navigationStateSubject = new BehaviorSubject<NavigationState>({
|
||||||
|
isNavigating: false,
|
||||||
|
currentUrl: '/',
|
||||||
|
previousUrl: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
public readonly navigationState$: Observable<NavigationState> = this.navigationStateSubject.asObservable();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private router: Router,
|
||||||
|
private location: Location,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {
|
||||||
|
this.initializeNavigationTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize navigation event tracking
|
||||||
|
*/
|
||||||
|
private initializeNavigationTracking(): void {
|
||||||
|
// Track navigation start
|
||||||
|
this.router.events.subscribe(event => {
|
||||||
|
if (event.constructor.name === 'NavigationStart') {
|
||||||
|
this.updateNavigationState({
|
||||||
|
...this.navigationStateSubject.value,
|
||||||
|
isNavigating: true,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track successful navigation
|
||||||
|
this.router.events.subscribe(event => {
|
||||||
|
if (event instanceof NavigationEnd) {
|
||||||
|
this.updateNavigationState({
|
||||||
|
previousUrl: this.navigationStateSubject.value.currentUrl,
|
||||||
|
currentUrl: event.url,
|
||||||
|
isNavigating: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track navigation errors
|
||||||
|
this.router.events.subscribe(event => {
|
||||||
|
if (event instanceof NavigationError) {
|
||||||
|
console.error('Navigation error:', event.error);
|
||||||
|
this.updateNavigationState({
|
||||||
|
...this.navigationStateSubject.value,
|
||||||
|
isNavigating: false,
|
||||||
|
error: event.error?.message || 'Navigation failed'
|
||||||
|
});
|
||||||
|
this.showErrorMessage('Navigation failed. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track navigation cancellations
|
||||||
|
this.router.events.subscribe(event => {
|
||||||
|
if (event instanceof NavigationCancel) {
|
||||||
|
console.warn('Navigation cancelled:', event.reason);
|
||||||
|
this.updateNavigationState({
|
||||||
|
...this.navigationStateSubject.value,
|
||||||
|
isNavigating: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update navigation state
|
||||||
|
*/
|
||||||
|
private updateNavigationState(state: NavigationState): void {
|
||||||
|
this.navigationStateSubject.next(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a route with comprehensive error handling
|
||||||
|
*/
|
||||||
|
async navigateToRoute(route: string | string[], options?: {
|
||||||
|
replaceUrl?: boolean;
|
||||||
|
queryParams?: any;
|
||||||
|
fragment?: string;
|
||||||
|
state?: any;
|
||||||
|
skipLocationChange?: boolean;
|
||||||
|
preserveFragment?: boolean;
|
||||||
|
preserveQueryParams?: boolean;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const navigationResult = await this.router.navigate(
|
||||||
|
Array.isArray(route) ? route : [route],
|
||||||
|
{
|
||||||
|
replaceUrl: options?.replaceUrl || false,
|
||||||
|
queryParams: options?.queryParams,
|
||||||
|
fragment: options?.fragment,
|
||||||
|
state: options?.state,
|
||||||
|
skipLocationChange: options?.skipLocationChange || false,
|
||||||
|
preserveFragment: options?.preserveFragment || false,
|
||||||
|
queryParamsHandling: options?.preserveQueryParams ? 'merge' : undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!navigationResult) {
|
||||||
|
console.error('Navigation failed for route:', route);
|
||||||
|
this.showErrorMessage('Unable to navigate to the requested page.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Navigation exception:', error);
|
||||||
|
this.showErrorMessage('An unexpected error occurred during navigation.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to login page with return URL
|
||||||
|
*/
|
||||||
|
async navigateToLogin(returnUrl?: string): Promise<boolean> {
|
||||||
|
const queryParams = returnUrl ? { returnUrl } : undefined;
|
||||||
|
return await this.navigateToRoute('/auth/login', { queryParams });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to dashboard
|
||||||
|
*/
|
||||||
|
async navigateToDashboard(): Promise<boolean> {
|
||||||
|
return await this.navigateToRoute('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to resume builder
|
||||||
|
*/
|
||||||
|
async navigateToBuilder(): Promise<boolean> {
|
||||||
|
return await this.navigateToRoute('/builder');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to user profile
|
||||||
|
*/
|
||||||
|
async navigateToProfile(): Promise<boolean> {
|
||||||
|
return await this.navigateToRoute('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to settings
|
||||||
|
*/
|
||||||
|
async navigateToSettings(): Promise<boolean> {
|
||||||
|
return await this.navigateToRoute('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to home page
|
||||||
|
*/
|
||||||
|
async navigateToHome(): Promise<boolean> {
|
||||||
|
return await this.navigateToRoute('/home', { replaceUrl: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate back to previous page
|
||||||
|
*/
|
||||||
|
navigateBack(): void {
|
||||||
|
const previousUrl = this.navigationStateSubject.value.previousUrl;
|
||||||
|
if (previousUrl && previousUrl !== this.navigationStateSubject.value.currentUrl) {
|
||||||
|
this.location.back();
|
||||||
|
} else {
|
||||||
|
this.navigateToHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate forward in browser history
|
||||||
|
*/
|
||||||
|
navigateForward(): void {
|
||||||
|
this.location.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current URL
|
||||||
|
*/
|
||||||
|
getCurrentUrl(): string {
|
||||||
|
return this.router.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently on a specific route
|
||||||
|
*/
|
||||||
|
isCurrentRoute(route: string): boolean {
|
||||||
|
return this.router.url === route || this.router.url.startsWith(route + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if route is public (doesn't require authentication)
|
||||||
|
*/
|
||||||
|
isPublicRoute(url?: string): boolean {
|
||||||
|
const currentUrl = url || this.router.url;
|
||||||
|
const publicRoutes = ['/home', '/login', '/register', '/forgot-password', '/auth', '/404', '/403', '/500'];
|
||||||
|
return publicRoutes.some(route => currentUrl.startsWith(route)) || currentUrl === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force reload current page
|
||||||
|
*/
|
||||||
|
reloadCurrentPage(): void {
|
||||||
|
const currentUrl = this.router.url;
|
||||||
|
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
|
||||||
|
this.router.navigate([currentUrl]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open external URL in new tab
|
||||||
|
*/
|
||||||
|
openExternalUrl(url: string): void {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show success message to user
|
||||||
|
*/
|
||||||
|
private showSuccessMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 4000,
|
||||||
|
panelClass: ['success-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error message to user
|
||||||
|
*/
|
||||||
|
private showErrorMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 6000,
|
||||||
|
panelClass: ['error-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show info message to user
|
||||||
|
*/
|
||||||
|
private showInfoMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['info-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/app/guards/auth.guard.ts
Normal file
181
src/app/guards/auth.guard.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Guards
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
CanActivateChild,
|
||||||
|
CanLoad,
|
||||||
|
Router,
|
||||||
|
ActivatedRouteSnapshot,
|
||||||
|
RouterStateSnapshot,
|
||||||
|
Route,
|
||||||
|
UrlSegment
|
||||||
|
} from '@angular/router';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { map, take, tap } from 'rxjs/operators';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { UserRole } from '../models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Guard - Protects routes that require authentication
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
canActivate(
|
||||||
|
route: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot
|
||||||
|
): Observable<boolean> {
|
||||||
|
return this.checkAuthStatus(state.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivateChild(
|
||||||
|
route: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot
|
||||||
|
): Observable<boolean> {
|
||||||
|
return this.canActivate(route, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
canLoad(route: Route, segments: UrlSegment[]): Observable<boolean> {
|
||||||
|
const url = segments.map(segment => segment.path).join('/');
|
||||||
|
return this.checkAuthStatus(`/${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check authentication status and redirect if necessary
|
||||||
|
*/
|
||||||
|
private checkAuthStatus(redirectUrl: string): Observable<boolean> {
|
||||||
|
return this.authService.isAuthenticated$.pipe(
|
||||||
|
take(1),
|
||||||
|
tap(isAuthenticated => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: { returnUrl: redirectUrl },
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-based Authorization Guard - Protects routes based on user roles
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class RoleGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
|
||||||
|
const requiredRoles = route.data['roles'] as UserRole[];
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
return of(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authService.currentUser$.pipe(
|
||||||
|
take(1),
|
||||||
|
map(user => {
|
||||||
|
if (!user) {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequiredRole = requiredRoles.some(role => user.roles.includes(role));
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
this.router.navigate(['/access-denied']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission-based Authorization Guard - Protects routes based on user permissions
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class PermissionGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
|
||||||
|
const requiredPermissions = route.data['permissions'] as string[];
|
||||||
|
|
||||||
|
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||||
|
return of(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authService.currentUser$.pipe(
|
||||||
|
take(1),
|
||||||
|
map(user => {
|
||||||
|
if (!user) {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequiredPermission = requiredPermissions.some(permission =>
|
||||||
|
(user.permissions || []).includes(permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRequiredPermission) {
|
||||||
|
this.router.navigate(['/access-denied']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guest Guard - Redirects authenticated users away from auth pages
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class GuestGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router
|
||||||
|
) {}
|
||||||
|
|
||||||
|
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
|
||||||
|
return this.authService.isAuthenticated$.pipe(
|
||||||
|
take(1),
|
||||||
|
tap(isAuthenticated => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const returnUrl = route.queryParams['returnUrl'];
|
||||||
|
this.router.navigate([returnUrl || '/dashboard']);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
map(isAuthenticated => !isAuthenticated)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/app/interceptors/jwt-interceptor.function.ts
Normal file
118
src/app/interceptors/jwt-interceptor.function.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* JWT Authentication Interceptor Function
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpInterceptorFn, HttpErrorResponse, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { catchError, switchMap, throwError, BehaviorSubject, filter, take, Observable } from 'rxjs';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
const refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Authentication Interceptor Function
|
||||||
|
* Automatically adds JWT tokens to requests and handles token refresh
|
||||||
|
*/
|
||||||
|
export const jwtInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
|
||||||
|
// Skip interceptor for authentication endpoints
|
||||||
|
if (req.url.includes('/auth/login') || req.url.includes('/auth/register')) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add JWT token to request if user is authenticated
|
||||||
|
const token = getTokenFromStorage();
|
||||||
|
let authReq = req;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
authReq = req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(authReq).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
if (error instanceof HttpErrorResponse && error.status === 401) {
|
||||||
|
return handle401Error(authReq, next, authService);
|
||||||
|
}
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 401 Unauthorized errors with token refresh
|
||||||
|
*/
|
||||||
|
function handle401Error(request: HttpRequest<any>, next: HttpHandlerFn, authService: AuthService): Observable<any> {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshTokenSubject.next(null);
|
||||||
|
|
||||||
|
const refreshToken = getRefreshTokenFromStorage();
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
return authService.refreshToken().pipe(
|
||||||
|
switchMap((tokens: any) => {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshTokenSubject.next(tokens.accessToken);
|
||||||
|
|
||||||
|
// Retry the original request with new token
|
||||||
|
const newAuthReq = request.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${tokens.accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return next(newAuthReq);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
isRefreshing = false;
|
||||||
|
authService.logout();
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If refresh is in progress, wait for it to complete
|
||||||
|
return refreshTokenSubject.pipe(
|
||||||
|
filter(token => token !== null),
|
||||||
|
take(1),
|
||||||
|
switchMap((token) => {
|
||||||
|
const newAuthReq = request.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next(newAuthReq);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get access token from storage
|
||||||
|
*/
|
||||||
|
function getTokenFromStorage(): string | null {
|
||||||
|
return localStorage.getItem('david_auth_access_token') ||
|
||||||
|
sessionStorage.getItem('david_auth_access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get refresh token from storage
|
||||||
|
*/
|
||||||
|
function getRefreshTokenFromStorage(): string | null {
|
||||||
|
return localStorage.getItem('david_auth_refresh_token') ||
|
||||||
|
sessionStorage.getItem('david_auth_refresh_token');
|
||||||
|
}
|
||||||
122
src/app/interceptors/jwt.interceptor.ts
Normal file
122
src/app/interceptors/jwt.interceptor.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* JWT Authentication Interceptor
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
HttpRequest,
|
||||||
|
HttpHandler,
|
||||||
|
HttpEvent,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpErrorResponse
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { Observable, throwError, BehaviorSubject } from 'rxjs';
|
||||||
|
import { catchError, filter, take, switchMap } from 'rxjs/operators';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enterprise-grade JWT Authentication Interceptor
|
||||||
|
* Automatically adds JWT tokens to requests and handles token refresh
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JwtInterceptor implements HttpInterceptor {
|
||||||
|
private isRefreshing = false;
|
||||||
|
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
|
||||||
|
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept HTTP requests to add JWT token and handle authentication errors
|
||||||
|
*/
|
||||||
|
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
// Add JWT token to request if user is authenticated
|
||||||
|
if (this.authService.isAuthenticated()) {
|
||||||
|
request = this.addTokenToRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle(request).pipe(
|
||||||
|
catchError(error => {
|
||||||
|
if (error instanceof HttpErrorResponse && error.status === 401) {
|
||||||
|
return this.handle401Error(request, next);
|
||||||
|
}
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add JWT token to request headers
|
||||||
|
*/
|
||||||
|
private addTokenToRequest(request: HttpRequest<any>): HttpRequest<any> {
|
||||||
|
const token = this.getTokenFromStorage();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
return request.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 401 Unauthorized errors with token refresh
|
||||||
|
*/
|
||||||
|
private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
if (!this.isRefreshing) {
|
||||||
|
this.isRefreshing = true;
|
||||||
|
this.refreshTokenSubject.next(null);
|
||||||
|
|
||||||
|
const refreshToken = this.getRefreshTokenFromStorage();
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
return this.authService.refreshToken().pipe(
|
||||||
|
switchMap((tokens: any) => {
|
||||||
|
this.isRefreshing = false;
|
||||||
|
this.refreshTokenSubject.next(tokens.accessToken);
|
||||||
|
|
||||||
|
// Retry the original request with new token
|
||||||
|
return next.handle(this.addTokenToRequest(request));
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
this.isRefreshing = false;
|
||||||
|
this.authService.logout();
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If refresh is in progress, wait for it to complete
|
||||||
|
return this.refreshTokenSubject.pipe(
|
||||||
|
filter(token => token !== null),
|
||||||
|
take(1),
|
||||||
|
switchMap(() => next.handle(this.addTokenToRequest(request)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get access token from storage
|
||||||
|
*/
|
||||||
|
private getTokenFromStorage(): string | null {
|
||||||
|
return localStorage.getItem('david_auth_access_token') ||
|
||||||
|
sessionStorage.getItem('david_auth_access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get refresh token from storage
|
||||||
|
*/
|
||||||
|
private getRefreshTokenFromStorage(): string | null {
|
||||||
|
return localStorage.getItem('david_auth_refresh_token') ||
|
||||||
|
sessionStorage.getItem('david_auth_refresh_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/layouts/auth-layout/auth-layout.component.css
Normal file
20
src/app/layouts/auth-layout/auth-layout.component.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Layout Component Styles
|
||||||
|
* Professional Angular Resume Builder - Authentication Pages Layout
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Layout wrapper for authentication pages (login, register, forgot password)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Auth Layout Container
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.auth-layout-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
12
src/app/layouts/auth-layout/auth-layout.component.html
Normal file
12
src/app/layouts/auth-layout/auth-layout.component.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!--
|
||||||
|
Auth Layout Template
|
||||||
|
Clean layout for authentication pages without navigation
|
||||||
|
|
||||||
|
@author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
@created 2025-08-08
|
||||||
|
@location Made in Germany 🇩🇪
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="auth-layout-container">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</div>
|
||||||
26
src/app/layouts/auth-layout/auth-layout.component.ts
Normal file
26
src/app/layouts/auth-layout/auth-layout.component.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Auth Layout Component
|
||||||
|
* Clean layout for authentication pages (login, register, forgot-password)
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-auth-layout',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterOutlet
|
||||||
|
],
|
||||||
|
templateUrl: './auth-layout.component.html',
|
||||||
|
styleUrls: ['./auth-layout.component.css']
|
||||||
|
})
|
||||||
|
export class AuthLayoutComponent {
|
||||||
|
// Simple layout component for auth pages - no logic needed
|
||||||
|
}
|
||||||
264
src/app/layouts/main-layout/main-layout.component.css
Normal file
264
src/app/layouts/main-layout/main-layout.component.css
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Main Layout Component Styles
|
||||||
|
* Professional Angular Resume Builder - Application Shell & Navigation
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Main application layout with header, navigation, and content areas
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Main Layout Container
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.main-layout-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Application Toolbar
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.app-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--z-index-fixed);
|
||||||
|
box-shadow: var(--color-shadow-light) 0px 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button.active-link {
|
||||||
|
background-color: var(--color-surface-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Display Styles */
|
||||||
|
.user-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-display:hover {
|
||||||
|
background: var(--color-background-white-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-display {
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email-display {
|
||||||
|
color: var(--color-text-white-light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
color: white;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-dropdown {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-white-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-button {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--color-background-dark-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-item {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-item mat-icon {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content router-outlet + * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toolbar-content {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-section {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-display {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-display {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-display,
|
||||||
|
.user-email-display {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.brand-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
font-size: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/app/layouts/main-layout/main-layout.component.html
Normal file
103
src/app/layouts/main-layout/main-layout.component.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!--
|
||||||
|
Main Layout Template
|
||||||
|
Layout wrapper for authenticated pages with navigation header
|
||||||
|
|
||||||
|
@author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
@created 2025-08-08
|
||||||
|
@location Made in Germany 🇩🇪
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="main-layout-container">
|
||||||
|
<!-- Top Navigation Toolbar -->
|
||||||
|
<mat-toolbar color="primary" class="app-toolbar">
|
||||||
|
<div class="toolbar-content">
|
||||||
|
<!-- Brand Section -->
|
||||||
|
<div class="brand-section" (click)="navigateToDashboard()" style="cursor: pointer;">
|
||||||
|
<mat-icon class="brand-icon">description</mat-icon>
|
||||||
|
<span class="brand-text">Lebenslauf Generator</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spacer -->
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<!-- User Section with Name/Email Display -->
|
||||||
|
<div class="user-section" *ngIf="isAuthenticated$ | async">
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<div *ngIf="isLoading$ | async" class="loading-indicator">
|
||||||
|
<mat-icon class="loading-icon">hourglass_empty</mat-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Info Display -->
|
||||||
|
<div *ngIf="!(isLoading$ | async) && (currentUser$ | async) as user" class="user-display">
|
||||||
|
<mat-icon class="user-avatar">account_circle</mat-icon>
|
||||||
|
<div class="user-info-display">
|
||||||
|
<div class="user-name-display">{{ user.firstName }} {{ user.lastName }}</div>
|
||||||
|
<div class="user-email-display">{{ user.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Menu Button -->
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
[matMenuTriggerFor]="mainMenu"
|
||||||
|
class="menu-button"
|
||||||
|
[attr.aria-label]="'Main menu'"
|
||||||
|
*ngIf="!(isLoading$ | async)"
|
||||||
|
>
|
||||||
|
<mat-icon>menu</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<mat-menu #mainMenu="matMenu" class="main-dropdown">
|
||||||
|
<!-- Dashboard Link -->
|
||||||
|
<button mat-menu-item (click)="navigateToDashboard()">
|
||||||
|
<mat-icon>dashboard</mat-icon>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Navigation Items -->
|
||||||
|
<button mat-menu-item routerLink="/builder">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
<span>Lebenslauf Generator</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-menu-item routerLink="/preview">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
<span>Lebenslauf Vorschau</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<button mat-menu-item (click)="navigateToProfile()">
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
<span>Einstellungen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<button mat-menu-item (click)="logout()" class="logout-item">
|
||||||
|
<mat-icon>logout</mat-icon>
|
||||||
|
<span>Abmelden</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="main-content">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="app-footer" *ngIf="!(isAuthenticated$ | async)">
|
||||||
|
<div class="footer-content">
|
||||||
|
<p class="footer-text">
|
||||||
|
© 2025 David Valera Melendez | Professioneller Lebenslauf Generator | Made in Germany 🇩🇪
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
160
src/app/layouts/main-layout/main-layout.component.ts
Normal file
160
src/app/layouts/main-layout/main-layout.component.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Main Layout Component
|
||||||
|
* Layout wrapper for authenticated pages with navigation header
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterOutlet, RouterModule, Router } from '@angular/router';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { Observable, Subject } from 'rxjs';
|
||||||
|
import { takeUntil, finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { AuthUser } from '../../models/auth.model';
|
||||||
|
import { NavigationService } from '../../core/services/navigation.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-main-layout',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterOutlet,
|
||||||
|
RouterModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
],
|
||||||
|
templateUrl: './main-layout.component.html',
|
||||||
|
styleUrls: ['./main-layout.component.css']
|
||||||
|
})
|
||||||
|
export class MainLayoutComponent implements OnInit, OnDestroy {
|
||||||
|
// Authentication state observables
|
||||||
|
isAuthenticated$: Observable<boolean>;
|
||||||
|
currentUser$: Observable<AuthUser | null>;
|
||||||
|
isLoading$: Observable<boolean>;
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
private isLoggingOut = false;
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private navigationService: NavigationService
|
||||||
|
) {
|
||||||
|
this.isAuthenticated$ = this.authService.isAuthenticated$;
|
||||||
|
this.currentUser$ = this.authService.currentUser$;
|
||||||
|
this.isLoading$ = this.authService.isLoading$;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Layout component doesn't need auth watcher - that's handled at app level
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle user logout with comprehensive error handling and user feedback
|
||||||
|
*/
|
||||||
|
logout(): void {
|
||||||
|
if (this.isLoggingOut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoggingOut = true;
|
||||||
|
|
||||||
|
const logoutMessage = this.snackBar.open('Signing out...', undefined, {
|
||||||
|
duration: 0,
|
||||||
|
panelClass: ['info-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
this.authService.logout()
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
finalize(() => {
|
||||||
|
this.isLoggingOut = false;
|
||||||
|
logoutMessage.dismiss();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: async () => {
|
||||||
|
this.showSuccessMessage('Successfully signed out. See you next time!');
|
||||||
|
const success = await this.navigationService.navigateToHome();
|
||||||
|
if (!success) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: async (error) => {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
this.showErrorMessage('Logout encountered an issue, but you have been signed out.');
|
||||||
|
const success = await this.navigationService.navigateToHome();
|
||||||
|
if (!success) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to user profile/settings
|
||||||
|
*/
|
||||||
|
async navigateToProfile(): Promise<void> {
|
||||||
|
const success = await this.navigationService.navigateToSettings();
|
||||||
|
if (!success) {
|
||||||
|
this.showErrorMessage('Unable to open settings page.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to dashboard
|
||||||
|
*/
|
||||||
|
async navigateToDashboard(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.router.navigate(['/home']);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Navigation error:', error);
|
||||||
|
this.showErrorMessage('Unable to open dashboard.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display success message to user
|
||||||
|
*/
|
||||||
|
private showSuccessMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 4000,
|
||||||
|
panelClass: ['success-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display error message to user
|
||||||
|
*/
|
||||||
|
private showErrorMessage(message: string): void {
|
||||||
|
this.snackBar.open(message, 'Close', {
|
||||||
|
duration: 6000,
|
||||||
|
panelClass: ['error-snackbar'],
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
282
src/app/models/auth.model.ts
Normal file
282
src/app/models/auth.model.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Models
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User authentication credentials interface
|
||||||
|
*/
|
||||||
|
export interface LoginCredentials {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User registration data interface
|
||||||
|
*/
|
||||||
|
export interface RegisterData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
acceptTerms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User registration request interface (for API calls)
|
||||||
|
*/
|
||||||
|
export interface RegisterRequest {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
acceptTerms: boolean;
|
||||||
|
newsletter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT token response interface
|
||||||
|
* Compatible with both simple and full token responses
|
||||||
|
*/
|
||||||
|
export interface AuthTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
tokenType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated user profile interface
|
||||||
|
*/
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions?: string[];
|
||||||
|
emailVerified?: boolean;
|
||||||
|
profileImage?: string;
|
||||||
|
lastLoginAt?: Date;
|
||||||
|
createdAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User role enumeration
|
||||||
|
*/
|
||||||
|
export enum UserRole {
|
||||||
|
ADMIN = 'admin',
|
||||||
|
USER = 'user',
|
||||||
|
MODERATOR = 'moderator',
|
||||||
|
GUEST = 'guest'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication response interface
|
||||||
|
*/
|
||||||
|
export interface AuthResponse {
|
||||||
|
message?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
user: AuthUser;
|
||||||
|
requiresTwoFactor: boolean;
|
||||||
|
reason?: string;
|
||||||
|
riskScore?: number;
|
||||||
|
tempToken?: string | null;
|
||||||
|
deviceInfo?: {
|
||||||
|
isTrusted: boolean;
|
||||||
|
deviceName?: string;
|
||||||
|
lastUsed?: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password reset request interface
|
||||||
|
*/
|
||||||
|
export interface PasswordResetRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password reset confirmation interface
|
||||||
|
*/
|
||||||
|
export interface PasswordResetConfirmation {
|
||||||
|
token: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication error interface
|
||||||
|
*/
|
||||||
|
export interface AuthError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT token payload interface
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: number; // User ID
|
||||||
|
email: string;
|
||||||
|
roles?: string[];
|
||||||
|
iat: number; // Issued at
|
||||||
|
exp: number; // Expires at
|
||||||
|
iss?: string; // Issuer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session information interface
|
||||||
|
*/
|
||||||
|
export interface SessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
deviceInfo: string;
|
||||||
|
ipAddress: string;
|
||||||
|
userAgent: string;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication setup interface
|
||||||
|
*/
|
||||||
|
export interface TwoFactorSetup {
|
||||||
|
secret: string;
|
||||||
|
qrCode: string;
|
||||||
|
backupCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication verification interface
|
||||||
|
*/
|
||||||
|
export interface TwoFactorVerification {
|
||||||
|
token: string;
|
||||||
|
backupCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device fingerprint interface
|
||||||
|
*/
|
||||||
|
export interface DeviceFingerprint {
|
||||||
|
userAgent: string;
|
||||||
|
acceptLanguage: string;
|
||||||
|
screenResolution: string;
|
||||||
|
timezone: string;
|
||||||
|
platform: string;
|
||||||
|
cookieEnabled: boolean;
|
||||||
|
doNotTrack: boolean;
|
||||||
|
fontsHash?: string;
|
||||||
|
canvasFingerprint?: string;
|
||||||
|
webglFingerprint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device verification request interface
|
||||||
|
*/
|
||||||
|
export interface DeviceVerificationRequest {
|
||||||
|
userId: number;
|
||||||
|
fingerprint: DeviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device verification response interface
|
||||||
|
*/
|
||||||
|
export interface DeviceVerificationResponse {
|
||||||
|
deviceId: string;
|
||||||
|
isKnownDevice: boolean;
|
||||||
|
isTrustedDevice: boolean;
|
||||||
|
requiresTwoFactor: boolean;
|
||||||
|
device: {
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
registeredAt: Date;
|
||||||
|
lastUsedAt?: Date;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication initiation request interface
|
||||||
|
*/
|
||||||
|
export interface TwoFactorInitiationRequest {
|
||||||
|
userId: number;
|
||||||
|
method?: 'sms' | 'email' | 'totp'; // Optional - backend can default to email
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication initiation response interface
|
||||||
|
*/
|
||||||
|
export interface TwoFactorInitiationResponse {
|
||||||
|
verificationId: string;
|
||||||
|
method: string;
|
||||||
|
message: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
attemptsRemaining: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
mode: string;
|
||||||
|
verificationCode?: string; // Only in demo mode
|
||||||
|
userInfo?: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
};
|
||||||
|
environmentInfo: {
|
||||||
|
environment: string;
|
||||||
|
demoModeEnabled: boolean;
|
||||||
|
timestamp: string;
|
||||||
|
securityLevel: string;
|
||||||
|
purpose: string;
|
||||||
|
};
|
||||||
|
securityInfo: {
|
||||||
|
rateLimitingEnabled: boolean;
|
||||||
|
auditLoggingEnabled: boolean;
|
||||||
|
sessionId: string;
|
||||||
|
ipAddress: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication verification request interface
|
||||||
|
*/
|
||||||
|
export interface TwoFactorVerificationRequest {
|
||||||
|
code: string;
|
||||||
|
verificationId: string;
|
||||||
|
userId: number;
|
||||||
|
tempToken?: string; // Optional temp token for completing authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication page data interface
|
||||||
|
* Used for passing secure data to the 2FA page component
|
||||||
|
*/
|
||||||
|
export interface TwoFactorPageData {
|
||||||
|
userId: number;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
reason?: string;
|
||||||
|
riskScore?: number;
|
||||||
|
flowType?: 'device_registration' | 'code_required';
|
||||||
|
deviceInfo?: {
|
||||||
|
isTrusted: boolean;
|
||||||
|
deviceName?: string;
|
||||||
|
lastUsed?: Date;
|
||||||
|
};
|
||||||
|
tempToken?: string | null;
|
||||||
|
timestamp?: number; // For data expiration checking
|
||||||
|
}
|
||||||
14
src/app/models/index.ts
Normal file
14
src/app/models/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Models Barrel Export
|
||||||
|
* Professional Angular Resume Builder - Centralized Model Exports
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Authentication Models
|
||||||
|
export * from './auth.model';
|
||||||
|
|
||||||
|
// Resume Models
|
||||||
|
export * from './resume.model';
|
||||||
241
src/app/models/resume.model.ts
Normal file
241
src/app/models/resume.model.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Resume Data Models
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal Information Interface
|
||||||
|
* Contains all personal and contact details
|
||||||
|
*/
|
||||||
|
export interface PersonalInfo {
|
||||||
|
// Basic Identity
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
profileImage?: string;
|
||||||
|
|
||||||
|
// Contact Information
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
street?: string;
|
||||||
|
houseNumber?: string;
|
||||||
|
zipCode?: string;
|
||||||
|
city: string;
|
||||||
|
country?: string;
|
||||||
|
location: string; // Keep for backward compatibility
|
||||||
|
|
||||||
|
// Personal Details (German CV style)
|
||||||
|
nationality?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
placeOfBirth?: string;
|
||||||
|
maritalStatus?: string;
|
||||||
|
|
||||||
|
// Professional Links
|
||||||
|
website?: string;
|
||||||
|
linkedin?: string;
|
||||||
|
github?: string;
|
||||||
|
portfolio?: string;
|
||||||
|
|
||||||
|
// Professional Summary
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work Experience Interface
|
||||||
|
* Professional work history with achievements
|
||||||
|
*/
|
||||||
|
export interface Experience {
|
||||||
|
id: string;
|
||||||
|
position: string;
|
||||||
|
company: string;
|
||||||
|
location?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string; // null/undefined for current position
|
||||||
|
isCurrentPosition?: boolean;
|
||||||
|
description: string;
|
||||||
|
achievements?: string[];
|
||||||
|
technologies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Education Interface
|
||||||
|
* Academic background and qualifications
|
||||||
|
*/
|
||||||
|
export interface Education {
|
||||||
|
id: string;
|
||||||
|
degree: string;
|
||||||
|
institution: string;
|
||||||
|
location?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate?: string;
|
||||||
|
grade?: string;
|
||||||
|
gpa?: string;
|
||||||
|
description?: string;
|
||||||
|
achievements?: string[];
|
||||||
|
relevantCourses?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill Interface with proficiency levels
|
||||||
|
*/
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: 'technical' | 'soft' | 'language' | 'other';
|
||||||
|
proficiency?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||||
|
proficiencyLevel?: number; // 1-10 scale
|
||||||
|
yearsOfExperience?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language Skill Interface
|
||||||
|
*/
|
||||||
|
export interface Language {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
proficiency: 'native' | 'fluent' | 'conversational' | 'basic';
|
||||||
|
level?: string; // e.g., "C2", "B2", "A1"
|
||||||
|
certifications?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certification Interface
|
||||||
|
*/
|
||||||
|
export interface Certification {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
issuer: string;
|
||||||
|
issueDate: string;
|
||||||
|
expirationDate?: string;
|
||||||
|
credentialId?: string;
|
||||||
|
credentialUrl?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project Interface
|
||||||
|
*/
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
role?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
technologies?: string[];
|
||||||
|
url?: string;
|
||||||
|
githubUrl?: string;
|
||||||
|
achievements?: string[];
|
||||||
|
status?: 'completed' | 'ongoing' | 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award/Achievement Interface
|
||||||
|
*/
|
||||||
|
export interface Award {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
issuer: string;
|
||||||
|
date: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference Interface
|
||||||
|
*/
|
||||||
|
export interface Reference {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
company: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
relationship: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete Resume Data Interface
|
||||||
|
* Main data structure for the entire resume
|
||||||
|
*/
|
||||||
|
export interface ResumeData {
|
||||||
|
// Personal Information
|
||||||
|
personalInfo: PersonalInfo;
|
||||||
|
|
||||||
|
// Professional Experience
|
||||||
|
experience: Experience[];
|
||||||
|
|
||||||
|
// Education
|
||||||
|
education: Education[];
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
skills: Skill[];
|
||||||
|
|
||||||
|
// Languages
|
||||||
|
languages: Language[];
|
||||||
|
|
||||||
|
// Certifications
|
||||||
|
certifications: Certification[];
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
projects: Project[];
|
||||||
|
|
||||||
|
// Awards
|
||||||
|
awards: Award[];
|
||||||
|
|
||||||
|
// References
|
||||||
|
references: Reference[];
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
version?: string;
|
||||||
|
template?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form Step Interface for Resume Builder
|
||||||
|
*/
|
||||||
|
export interface ResumeStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
completed: boolean;
|
||||||
|
optional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template Configuration Interface
|
||||||
|
*/
|
||||||
|
export interface ResumeTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
preview?: string;
|
||||||
|
colors: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent?: string;
|
||||||
|
};
|
||||||
|
fonts: {
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
layout: 'modern' | 'classic' | 'creative' | 'minimal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export options for PDF generation
|
||||||
|
*/
|
||||||
|
export interface ExportOptions {
|
||||||
|
format: 'pdf' | 'png' | 'jpg';
|
||||||
|
template?: string;
|
||||||
|
includeSections?: string[];
|
||||||
|
pageSize?: 'A4' | 'Letter';
|
||||||
|
orientation?: 'portrait' | 'landscape';
|
||||||
|
}
|
||||||
74
src/app/pages/error/forbidden/forbidden.component.css
Normal file
74
src/app/pages/error/forbidden/forbidden.component.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 403 Forbidden Error Component Styles
|
||||||
|
* Professional Angular Resume Builder - Access Forbidden Page
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Professional 403 error page for unauthorized access attempts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Error Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon mat-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
width: 4rem;
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.error-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/pages/error/forbidden/forbidden.component.html
Normal file
23
src/app/pages/error/forbidden/forbidden.component.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<div class="error-container">
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-icon">
|
||||||
|
<mat-icon>block</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h1>403 - Access Denied</h1>
|
||||||
|
<p class="error-message">
|
||||||
|
You don't have permission to access this resource. Please contact an administrator if you believe this is an error.
|
||||||
|
</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button mat-raised-button color="primary" routerLink="/home">
|
||||||
|
<mat-icon>home</mat-icon>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button routerLink="/login" class="ml-2">
|
||||||
|
<mat-icon>login</mat-icon>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
30
src/app/pages/error/forbidden/forbidden.component.ts
Normal file
30
src/app/pages/error/forbidden/forbidden.component.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Forbidden Error Component (403)
|
||||||
|
* Professional error page for access denied scenarios
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-forbidden',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatCardModule
|
||||||
|
],
|
||||||
|
templateUrl: './forbidden.component.html',
|
||||||
|
styleUrls: ['./forbidden.component.css']
|
||||||
|
})
|
||||||
|
export class ForbiddenComponent { }
|
||||||
74
src/app/pages/error/not-found/not-found.component.css
Normal file
74
src/app/pages/error/not-found/not-found.component.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 404 Not Found Error Component Styles
|
||||||
|
* Professional Angular Resume Builder - 404 Error Page
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Professional 404 error page with navigation options and user-friendly messaging
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Error Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon mat-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
width: 4rem;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.error-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/pages/error/not-found/not-found.component.html
Normal file
23
src/app/pages/error/not-found/not-found.component.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<div class="error-container">
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-icon">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h1>404 - Seite nicht gefunden</h1>
|
||||||
|
<p class="error-message">
|
||||||
|
Entschuldigung, die gesuchte Seite existiert nicht oder wurde verschoben.
|
||||||
|
</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button mat-raised-button color="primary" routerLink="/home">
|
||||||
|
<mat-icon>home</mat-icon>
|
||||||
|
Zur Startseite
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button routerLink="/builder" class="ml-2">
|
||||||
|
<mat-icon>build</mat-icon>
|
||||||
|
Lebenslauf Generator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
30
src/app/pages/error/not-found/not-found.component.ts
Normal file
30
src/app/pages/error/not-found/not-found.component.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Not Found Error Component (404)
|
||||||
|
* Professional error page for missing routes
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-not-found',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatCardModule
|
||||||
|
],
|
||||||
|
templateUrl: './not-found.component.html',
|
||||||
|
styleUrls: ['./not-found.component.css']
|
||||||
|
})
|
||||||
|
export class NotFoundComponent { }
|
||||||
74
src/app/pages/error/server-error/server-error.component.css
Normal file
74
src/app/pages/error/server-error/server-error.component.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 500 Server Error Component Styles
|
||||||
|
* Professional Angular Resume Builder - Server Error Page
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Professional 500 error page for server-side errors with recovery options
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Error Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon mat-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
width: 4rem;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.error-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/pages/error/server-error/server-error.component.html
Normal file
23
src/app/pages/error/server-error/server-error.component.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<div class="error-container">
|
||||||
|
<mat-card class="error-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="error-icon">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h1>500 - Server Error</h1>
|
||||||
|
<p class="error-message">
|
||||||
|
We're experiencing technical difficulties. Our team has been notified and is working to resolve the issue.
|
||||||
|
</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button mat-raised-button color="primary" routerLink="/home">
|
||||||
|
<mat-icon>home</mat-icon>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button (click)="reloadPage()" class="ml-2">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
37
src/app/pages/error/server-error/server-error.component.ts
Normal file
37
src/app/pages/error/server-error/server-error.component.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Server Error Component (500)
|
||||||
|
* Professional error page for internal server errors
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-server-error',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatCardModule
|
||||||
|
],
|
||||||
|
templateUrl: './server-error.component.html',
|
||||||
|
styleUrls: ['./server-error.component.css']
|
||||||
|
})
|
||||||
|
export class ServerErrorComponent {
|
||||||
|
/**
|
||||||
|
* Reload the current page
|
||||||
|
*/
|
||||||
|
reloadPage(): void {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/app/pages/home/home.component.css
Normal file
230
src/app/pages/home/home.component.css
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Home Page Component Styles
|
||||||
|
* Professional Angular Resume Builder - Landing Page & Dashboard Overview
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Main landing page with hero section, feature highlights, and call-to-action
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Home Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.home-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Hero Section
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--border-radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon .large-icon {
|
||||||
|
font-size: 72px;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: var(--color-text-white-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-signature {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta, .secondary-cta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-section {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header p {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-avatar {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-highlights {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-highlights li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: var(--color-success);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-section {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 24px 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-item mat-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-description {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.home-container {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-stack {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.home-container {
|
||||||
|
padding: 24px 16px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.home-container {
|
||||||
|
padding: 16px 12px 0 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/pages/home/home.component.html
Normal file
86
src/app/pages/home/home.component.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<!-- Professional Home Page -->
|
||||||
|
<div class="home-container">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero-section">
|
||||||
|
<mat-card class="hero-card">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-icon">
|
||||||
|
<mat-icon class="large-icon">assignment_ind</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-title">Professioneller Lebenslauf Generator</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
Erstellen Sie beeindruckende, professionelle Lebensläufe mit modernem Design und fachkundiger Anleitung
|
||||||
|
</p>
|
||||||
|
<p class="author-signature">
|
||||||
|
<strong>Created by David Valera Melendez</strong> | Made in Germany 🇩🇪
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button mat-raised-button color="primary" routerLink="/builder" class="primary-cta">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Starten Sie Ihren Lebenslauf
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button routerLink="/preview" class="secondary-cta">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
Beispiel-Lebenslauf ansehen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<section class="features-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Warum unseren Lebenslauf Generator wählen?</h2>
|
||||||
|
<p>Professionelle Funktionen für moderne Jobsuchende</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-grid-list cols="3" rowHeight="300px" gutterSize="24px" class="features-grid">
|
||||||
|
<mat-grid-tile *ngFor="let feature of features">
|
||||||
|
<mat-card class="feature-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div mat-card-avatar class="feature-avatar">
|
||||||
|
<mat-icon>{{ feature.icon }}</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>{{ feature.title }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<p>{{ feature.description }}</p>
|
||||||
|
<ul class="feature-highlights">
|
||||||
|
<li *ngFor="let highlight of feature.highlights">
|
||||||
|
<mat-icon class="check-icon">check_circle</mat-icon>
|
||||||
|
{{ highlight }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</mat-grid-tile>
|
||||||
|
</mat-grid-list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Technology Stack Section -->
|
||||||
|
<section class="tech-section">
|
||||||
|
<mat-card class="tech-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>code</mat-icon>
|
||||||
|
Gebaut mit moderner Technologie
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="tech-stack">
|
||||||
|
<div class="tech-item" *ngFor="let tech of techStack">
|
||||||
|
<mat-icon>{{ tech.icon }}</mat-icon>
|
||||||
|
<span>{{ tech.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="tech-description">
|
||||||
|
Dieser Lebenslauf Generator wurde mit Angular 17, Material UI und TypeScript entwickelt,
|
||||||
|
um eine moderne, responsive und professionelle Benutzererfahrung zu gewährleisten.
|
||||||
|
</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
85
src/app/pages/home/home.component.ts
Normal file
85
src/app/pages/home/home.component.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Home Page Component
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatGridListModule } from '@angular/material/grid-list';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home page component - Landing page for the resume builder
|
||||||
|
* Features professional introduction and navigation to builder
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatGridListModule
|
||||||
|
],
|
||||||
|
templateUrl: './home.component.html',
|
||||||
|
styleUrls: ['./home.component.css']
|
||||||
|
})
|
||||||
|
export class HomeComponent {
|
||||||
|
/**
|
||||||
|
* Feature list for the home page
|
||||||
|
*/
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
title: 'Professionelle Vorlagen',
|
||||||
|
icon: 'design_services',
|
||||||
|
description: 'Wählen Sie aus professionell gestalteten Vorlagen, die für ATS-Systeme und moderne Personalgewinnung optimiert sind.',
|
||||||
|
highlights: [
|
||||||
|
'ATS-freundliche Formatierung',
|
||||||
|
'Moderne, saubere Designs',
|
||||||
|
'Anpassbare Themen',
|
||||||
|
'Druckfertige Layouts'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Intelligenter Generator',
|
||||||
|
icon: 'auto_awesome',
|
||||||
|
description: 'Schritt-für-Schritt-Anleitung mit intelligenten Vorschlägen und Echtzeit-Validierung.',
|
||||||
|
highlights: [
|
||||||
|
'Schritt-für-Schritt-Prozess',
|
||||||
|
'Live-Vorschau',
|
||||||
|
'Formular-Validierung',
|
||||||
|
'Intelligente Vorschläge'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Export & Teilen',
|
||||||
|
icon: 'cloud_download',
|
||||||
|
description: 'Exportieren Sie Ihren Lebenslauf als PDF oder teilen Sie ihn online mit professioneller Formatierung.',
|
||||||
|
highlights: [
|
||||||
|
'PDF-Export',
|
||||||
|
'Mehrere Formate',
|
||||||
|
'Cloud-Speicher',
|
||||||
|
'Einfaches Teilen'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technology stack used in the application
|
||||||
|
*/
|
||||||
|
techStack = [
|
||||||
|
{ name: 'Angular 17', icon: 'web' },
|
||||||
|
{ name: 'Material UI', icon: 'palette' },
|
||||||
|
{ name: 'TypeScript', icon: 'code' },
|
||||||
|
{ name: 'Responsive Design', icon: 'devices' }
|
||||||
|
];
|
||||||
|
}
|
||||||
707
src/app/pages/login/login.component.css
Normal file
707
src/app/pages/login/login.component.css
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
/**
|
||||||
|
* Login Page Styles
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Login Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container::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="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="var(--color-surface-overlay)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Brand Header
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.brand-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
height: 48px;
|
||||||
|
width: 48px;
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--color-surface-overlay-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 4px var(--color-text-shadow);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Login Card
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--color-background-white-overlay);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 40px var(--color-shadow-light),
|
||||||
|
0 4px 8px var(--color-shadow-subtle);
|
||||||
|
border: 1px solid var(--color-border-white-subtle);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 48px var(--color-shadow-deep),
|
||||||
|
0 8px 16px var(--color-shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Card Header
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.login-card .mat-mdc-card-header {
|
||||||
|
padding: 32px 32px 16px 32px;
|
||||||
|
background: var(--gradient-background);
|
||||||
|
border-bottom: 1px solid var(--color-border-dark-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-avatar {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-avatar mat-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-title {
|
||||||
|
font-size: 28px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: var(--color-text-gray-900) !important;
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-subtitle {
|
||||||
|
font-size: 16px !important;
|
||||||
|
color: var(--color-text-slate) !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Card Content & Form
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.mat-mdc-card-content {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Form Fields
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.mat-mdc-form-field {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field.mat-form-field-appearance-outline .mat-mdc-form-field-outline {
|
||||||
|
color: var(--color-disabled-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field.mat-focused .mat-mdc-form-field-outline-thick {
|
||||||
|
color: var(--color-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-label {
|
||||||
|
color: var(--color-text-slate);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-input-element {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-input-element::placeholder {
|
||||||
|
color: var(--color-text-slate-light);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error States */
|
||||||
|
.mat-mdc-form-field.mat-form-field-invalid .mat-mdc-form-field-outline-thick {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-error {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border-color: var(--color-error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Form Options
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.form-options {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-checkbox {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-checkbox-label {
|
||||||
|
color: var(--color-text-slate-dark);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-link {
|
||||||
|
color: var(--color-purple);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-link:hover {
|
||||||
|
color: var(--color-indigo);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Login Button
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 12px var(--color-purple-glow-medium);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover:not([disabled]) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px var(--color-purple-glow-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0 2px 4px var(--color-purple-glow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-spinner {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Demo Login Button
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.demo-button {
|
||||||
|
height: 42px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 2px solid var(--color-indigo-light);
|
||||||
|
color: var(--color-indigo);
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button:hover:not([disabled]) {
|
||||||
|
background: var(--color-indigo-light);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px var(--color-indigo-glow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-info {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-info-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Warnings & Alerts
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.lockout-warning {
|
||||||
|
background: var(--gradient-error-light);
|
||||||
|
border: 1px solid var(--color-error-border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 24px;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockout-message h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--color-error-text-dark);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockout-message p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-warning {
|
||||||
|
background: var(--gradient-warning-light);
|
||||||
|
border: 1px solid var(--color-warning-border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-warning .warning-icon {
|
||||||
|
color: var(--color-warning-text);
|
||||||
|
font-size: 20px;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-warning span {
|
||||||
|
color: var(--color-warning-text-dark);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Card Actions
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Social Login Section
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.social-login {
|
||||||
|
padding: 24px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid var(--color-border-dark-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-text {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: var(--color-text-slate);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-button {
|
||||||
|
flex: 1;
|
||||||
|
height: 44px;
|
||||||
|
border: 2px solid var(--color-slate-300);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-button:not(:disabled):hover {
|
||||||
|
border-color: var(--color-google);
|
||||||
|
color: var(--color-google);
|
||||||
|
background: var(--color-google-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.microsoft-button:not(:disabled):hover {
|
||||||
|
border-color: var(--color-microsoft);
|
||||||
|
color: var(--color-microsoft);
|
||||||
|
background: var(--color-microsoft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-disabled-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-slate-light);
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Register Section
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.register-section {
|
||||||
|
padding: 24px 32px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--color-slate-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-text {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: var(--color-text-slate);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-purple);
|
||||||
|
border: 2px solid var(--color-purple-brand);
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button:hover {
|
||||||
|
background: var(--color-purple-brand);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Security Info
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.security-info {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-white-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Footer
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-white-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: var(--color-text-white-subtle);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: white;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Responsive Design
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.login-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-content {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .mat-mdc-card-header {
|
||||||
|
padding: 24px 24px 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-login,
|
||||||
|
.register-section {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form-options {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-title {
|
||||||
|
font-size: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Dark Mode Support (Future Enhancement)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.login-card {
|
||||||
|
background: var(--color-overlay-dark);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-title {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-subtitle {
|
||||||
|
color: var(--color-text-slate-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .mat-mdc-card-header {
|
||||||
|
background: linear-gradient(135deg, var(--color-text-slate-700) 0%, var(--color-text-slate-600) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Animation Enhancements
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrapper {
|
||||||
|
animation: slideInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Focus Styles for Accessibility
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.forgot-password-link:focus,
|
||||||
|
.register-button:focus,
|
||||||
|
.footer-link:focus {
|
||||||
|
outline: 2px solid var(--color-purple-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
High Contrast Mode Support
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
.login-card {
|
||||||
|
border: 2px solid var(--color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field.mat-focused .mat-mdc-form-field-outline-thick {
|
||||||
|
color: var(--color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
background: var(--color-black);
|
||||||
|
color: var(--color-background);
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/app/pages/login/login.component.html
Normal file
221
src/app/pages/login/login.component.html
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<!--
|
||||||
|
Login Page Template
|
||||||
|
Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
|
||||||
|
@author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
@created 2025-08-08
|
||||||
|
@location Made in Germany 🇩🇪
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-wrapper">
|
||||||
|
<!-- Brand Header -->
|
||||||
|
<div class="brand-header">
|
||||||
|
<div class="brand-logo">
|
||||||
|
<mat-icon class="brand-icon">account_circle</mat-icon>
|
||||||
|
</div>
|
||||||
|
<h1 class="brand-title">Lebenslauf Generator</h1>
|
||||||
|
<p class="brand-subtitle">Professionelle Lebenslauf-Erstellungsplattform</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form Card -->
|
||||||
|
<mat-card class="login-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<div mat-card-avatar class="login-avatar">
|
||||||
|
<mat-icon>login</mat-icon>
|
||||||
|
</div>
|
||||||
|
<mat-card-title>Willkommen zurück</mat-card-title>
|
||||||
|
<mat-card-subtitle>Melden Sie sich bei Ihrem Konto an</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<!-- Account Lockout Warning -->
|
||||||
|
<div *ngIf="isLocked" class="lockout-warning">
|
||||||
|
<mat-icon class="warning-icon">lock</mat-icon>
|
||||||
|
<div class="lockout-message">
|
||||||
|
<h4>Konto vorübergehend gesperrt</h4>
|
||||||
|
<p>Zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es in {{ getRemainingLockoutTime() }} Minuten erneut.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
|
||||||
|
<!-- Email Field -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>E-Mail-Adresse</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="email"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="Geben Sie Ihre E-Mail ein"
|
||||||
|
autocomplete="email"
|
||||||
|
[class.error]="hasFieldError('email')"
|
||||||
|
>
|
||||||
|
<mat-icon matSuffix>email</mat-icon>
|
||||||
|
<mat-error *ngIf="hasFieldError('email')">
|
||||||
|
{{ getFieldError('email') }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Password Field -->
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Passwort</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[type]="hidePassword ? 'password' : 'text'"
|
||||||
|
formControlName="password"
|
||||||
|
placeholder="Geben Sie Ihr Passwort ein"
|
||||||
|
autocomplete="current-password"
|
||||||
|
[class.error]="hasFieldError('password')"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
(click)="togglePasswordVisibility()"
|
||||||
|
[attr.aria-label]="'Hide password'"
|
||||||
|
[attr.aria-pressed]="hidePassword"
|
||||||
|
>
|
||||||
|
<mat-icon>{{ hidePassword ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-error *ngIf="hasFieldError('password')">
|
||||||
|
{{ getFieldError('password') }}
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Remember Me & Forgot Password -->
|
||||||
|
<div class="form-options">
|
||||||
|
<mat-checkbox formControlName="rememberMe">
|
||||||
|
Angemeldet bleiben
|
||||||
|
</mat-checkbox>
|
||||||
|
<a
|
||||||
|
class="forgot-password-link"
|
||||||
|
(click)="navigateToPasswordReset()"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Button -->
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
class="login-button full-width"
|
||||||
|
[disabled]="isLoading || isLocked || loginForm.invalid"
|
||||||
|
>
|
||||||
|
<mat-spinner
|
||||||
|
*ngIf="isLoading"
|
||||||
|
diameter="20"
|
||||||
|
class="button-spinner">
|
||||||
|
</mat-spinner>
|
||||||
|
<span *ngIf="!isLoading">Anmelden</span>
|
||||||
|
<span *ngIf="isLoading">Anmelden...</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Demo Login Button -->
|
||||||
|
<button
|
||||||
|
*ngIf="isDemoEnabled()"
|
||||||
|
mat-stroked-button
|
||||||
|
color="accent"
|
||||||
|
type="button"
|
||||||
|
class="demo-button full-width"
|
||||||
|
[disabled]="isLoading || isLocked"
|
||||||
|
(click)="fillDemoCredentials()"
|
||||||
|
matTooltip="Formular mit Demo-Daten füllen"
|
||||||
|
matTooltipPosition="above"
|
||||||
|
>
|
||||||
|
<mat-icon class="demo-icon">auto_fix_high</mat-icon>
|
||||||
|
<span>Demo-Daten ausfüllen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Demo Info Text -->
|
||||||
|
<p *ngIf="isDemoEnabled()" class="demo-info">
|
||||||
|
<mat-icon class="demo-info-icon">info_outline</mat-icon>
|
||||||
|
Klicken Sie zum Ausfüllen des Formulars und drücken Sie dann "Anmelden" zum Einloggen
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Failed Attempts Warning -->
|
||||||
|
<div *ngIf="loginAttempts > 0 && loginAttempts < maxLoginAttempts" class="attempts-warning">
|
||||||
|
<mat-icon class="warning-icon">warning</mat-icon>
|
||||||
|
<span>{{ loginAttempts }}/{{ maxLoginAttempts }} fehlgeschlagene Versuche.
|
||||||
|
{{ maxLoginAttempts - loginAttempts }} Versuche verbleibend.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<mat-card-actions class="card-actions">
|
||||||
|
<!-- Social Login Options (Future Enhancement) -->
|
||||||
|
<div class="social-login">
|
||||||
|
<p class="social-text">Oder fortfahren mit</p>
|
||||||
|
<div class="social-buttons">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
class="social-button google-button"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<mat-icon class="social-icon">login</mat-icon>
|
||||||
|
Google
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
class="social-button microsoft-button"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<mat-icon class="social-icon">business</mat-icon>
|
||||||
|
Microsoft
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="social-disabled-note">
|
||||||
|
<mat-icon class="info-icon">info</mat-icon>
|
||||||
|
Social Login demnächst verfügbar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registration Link -->
|
||||||
|
<div class="register-section">
|
||||||
|
<p class="register-text">Haben Sie noch kein Konto?</p>
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
color="accent"
|
||||||
|
(click)="navigateToRegister()"
|
||||||
|
class="register-button"
|
||||||
|
>
|
||||||
|
Konto erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Security Features Info -->
|
||||||
|
<div class="security-info">
|
||||||
|
<div class="security-item">
|
||||||
|
<mat-icon class="security-icon">security</mat-icon>
|
||||||
|
<span>256-bit SSL-Verschlüsselung</span>
|
||||||
|
</div>
|
||||||
|
<div class="security-item">
|
||||||
|
<mat-icon class="security-icon">verified_user</mat-icon>
|
||||||
|
<span>Zwei-Faktor-Authentifizierung</span>
|
||||||
|
</div>
|
||||||
|
<div class="security-item">
|
||||||
|
<mat-icon class="security-icon">privacy_tip</mat-icon>
|
||||||
|
<span>DSGVO-konform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="login-footer">
|
||||||
|
<p class="footer-text">
|
||||||
|
© 2025 David Valera Melendez. Made in Germany 🇩🇪
|
||||||
|
</p>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="#" class="footer-link">Datenschutzerklärung</a>
|
||||||
|
<a href="#" class="footer-link">Nutzungsbedingungen</a>
|
||||||
|
<a href="#" class="footer-link">Support</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
525
src/app/pages/login/login.component.ts
Normal file
525
src/app/pages/login/login.component.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* Login Page Component
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule, Router, ActivatedRoute } from '@angular/router';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil, finalize, filter, take } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Angular Material Modules
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
|
||||||
|
// Auth Models and Services
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { LoginCredentials, AuthError, AuthResponse } from '../../models';
|
||||||
|
|
||||||
|
// Environment Configuration
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Professional Login Component with Enterprise Security Features
|
||||||
|
* Includes form validation, error handling, and security best practices
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatTooltipModule
|
||||||
|
],
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
styleUrls: ['./login.component.css']
|
||||||
|
})
|
||||||
|
export class LoginComponent implements OnInit, OnDestroy {
|
||||||
|
// Form Management
|
||||||
|
loginForm!: FormGroup;
|
||||||
|
|
||||||
|
// State Management
|
||||||
|
isLoading = false;
|
||||||
|
hidePassword = true;
|
||||||
|
loginAttempts = 0;
|
||||||
|
maxLoginAttempts = 5;
|
||||||
|
isLocked = false;
|
||||||
|
lockoutEndTime: Date | null = null;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
returnUrl = '/dashboard';
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {
|
||||||
|
this.initializeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.setupReturnUrl();
|
||||||
|
this.subscribeToAuthState();
|
||||||
|
this.checkAccountLockout();
|
||||||
|
this.handleTwoFactorReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize reactive form with validators
|
||||||
|
*/
|
||||||
|
private initializeForm(): void {
|
||||||
|
this.loginForm = this.formBuilder.group({
|
||||||
|
email: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.email,
|
||||||
|
Validators.maxLength(255)
|
||||||
|
]],
|
||||||
|
password: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(8),
|
||||||
|
Validators.maxLength(128)
|
||||||
|
]],
|
||||||
|
rememberMe: [false]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup return URL from query parameters
|
||||||
|
*/
|
||||||
|
private setupReturnUrl(): void {
|
||||||
|
this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to authentication state changes
|
||||||
|
*/
|
||||||
|
private subscribeToAuthState(): void {
|
||||||
|
this.authService.isLoading$
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe(loading => {
|
||||||
|
this.isLoading = loading;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle return from 2FA completion
|
||||||
|
*/
|
||||||
|
private handleTwoFactorReturn(): void {
|
||||||
|
this.route.queryParams.subscribe(params => {
|
||||||
|
if (params['twoFactorComplete'] === 'true' && params['email']) {
|
||||||
|
// Pre-fill the email field and show success message
|
||||||
|
this.loginForm.patchValue({ email: params['email'] });
|
||||||
|
this.snackBar.open('Device registered successfully! Please login again.', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['success-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the query parameters
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: {},
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account is locked due to failed attempts
|
||||||
|
*/
|
||||||
|
private checkAccountLockout(): void {
|
||||||
|
const lockoutData = localStorage.getItem('david_auth_lockout');
|
||||||
|
if (lockoutData) {
|
||||||
|
const { endTime, attempts } = JSON.parse(lockoutData);
|
||||||
|
this.lockoutEndTime = new Date(endTime);
|
||||||
|
|
||||||
|
if (new Date() < this.lockoutEndTime) {
|
||||||
|
this.isLocked = true;
|
||||||
|
this.loginAttempts = attempts;
|
||||||
|
this.startLockoutTimer();
|
||||||
|
} else {
|
||||||
|
// Lockout expired, clear it
|
||||||
|
localStorage.removeItem('david_auth_lockout');
|
||||||
|
this.loginAttempts = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle form submission with device verification and 2FA support
|
||||||
|
*/
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.loginForm.invalid || this.isLocked || this.isLoading) {
|
||||||
|
this.markFormGroupTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
const credentials: LoginCredentials = this.loginForm.value;
|
||||||
|
|
||||||
|
this.authService.login(credentials)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
finalize(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.requiresTwoFactor) {
|
||||||
|
// Determine the type of 2FA required based on reason/device info
|
||||||
|
this.handle2FARequired(response, credentials);
|
||||||
|
} else {
|
||||||
|
// Normal login success - no 2FA required
|
||||||
|
this.handleSuccessfulLogin();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error: AuthError) => {
|
||||||
|
this.handleLoginError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 2FA requirement - different scenarios based on reason
|
||||||
|
*/
|
||||||
|
private handle2FARequired(response: AuthResponse, credentials: LoginCredentials): void {
|
||||||
|
const reason = response.reason?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// Check for specific failure reasons
|
||||||
|
if (reason.includes('verification process failed') || reason.includes('verification failed')) {
|
||||||
|
// Device fingerprint verification failed - treat as new device registration
|
||||||
|
this.navigateTo2FAPage(response, credentials, 'device_registration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a new device scenario
|
||||||
|
const isNewDevice = reason.includes('new device') ||
|
||||||
|
reason.includes('unknown device') ||
|
||||||
|
reason.includes('unrecognized device') ||
|
||||||
|
(response.deviceInfo && !response.deviceInfo.isTrusted);
|
||||||
|
|
||||||
|
if (isNewDevice) {
|
||||||
|
// New/Unknown Device - Navigate to 2FA page for device registration
|
||||||
|
this.navigateTo2FAPage(response, credentials, 'device_registration');
|
||||||
|
} else {
|
||||||
|
// Existing device but requires 2FA code - Navigate to 2FA page for code verification
|
||||||
|
this.navigateTo2FAPage(response, credentials, 'code_required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to 2FA page with appropriate context
|
||||||
|
*/
|
||||||
|
private navigateTo2FAPage(response: AuthResponse, credentials: LoginCredentials, flowType: 'device_registration' | 'code_required'): void {
|
||||||
|
// Store 2FA data securely in sessionStorage (not in URL)
|
||||||
|
const twoFactorData = {
|
||||||
|
userId: response.user?.id || 0,
|
||||||
|
userEmail: response.user?.email || credentials.email,
|
||||||
|
userName: `${response.user?.firstName || ''} ${response.user?.lastName || ''}`.trim(),
|
||||||
|
reason: response.reason || (flowType === 'device_registration' ? 'New device detected' : '2FA code required'),
|
||||||
|
riskScore: response.riskScore || 0,
|
||||||
|
flowType: flowType,
|
||||||
|
deviceInfo: response.deviceInfo,
|
||||||
|
tempToken: response.tempToken, // For continuing auth after 2FA
|
||||||
|
timestamp: Date.now() // For expiration checking
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in sessionStorage (cleared when browser tab closes)
|
||||||
|
sessionStorage.setItem('twoFactorData', JSON.stringify(twoFactorData));
|
||||||
|
|
||||||
|
// Navigate to 2FA page without query parameters
|
||||||
|
const navigationPromise = this.router.navigate(['/two-factor-auth']);
|
||||||
|
|
||||||
|
navigationPromise.then(
|
||||||
|
(success) => {
|
||||||
|
}
|
||||||
|
).catch(
|
||||||
|
(error) => {
|
||||||
|
// Clear data if navigation fails
|
||||||
|
sessionStorage.removeItem('twoFactorData');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceed with login after successful 2FA device registration
|
||||||
|
*/
|
||||||
|
private proceedWithLoginAfter2FA(credentials: LoginCredentials): void {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
// The device should now be trusted, so login should work normally
|
||||||
|
this.authService.login(credentials)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
finalize(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.requiresTwoFactor) {
|
||||||
|
// This shouldn't happen after successful 2FA, but handle it just in case
|
||||||
|
this.snackBar.open('Additional verification required. Please contact support.', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['warning-snackbar']
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.handleSuccessfulLogin();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error: AuthError) => {
|
||||||
|
this.handleLoginError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful login
|
||||||
|
*/
|
||||||
|
private handleSuccessfulLogin(): void {
|
||||||
|
// Clear failed attempts
|
||||||
|
this.loginAttempts = 0;
|
||||||
|
localStorage.removeItem('david_auth_lockout');
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
this.snackBar.open('Login successful! Welcome back.', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['success-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for auth state to be set before navigating
|
||||||
|
this.authService.isAuthenticated$.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
// Wait for authenticated state
|
||||||
|
filter(isAuthenticated => isAuthenticated === true),
|
||||||
|
// Take only the first emission
|
||||||
|
take(1)
|
||||||
|
).subscribe(() => {
|
||||||
|
// Navigate to return URL once authenticated
|
||||||
|
this.router.navigate([this.returnUrl]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle login errors with security measures
|
||||||
|
*/
|
||||||
|
private handleLoginError(error: AuthError): void {
|
||||||
|
this.loginAttempts++;
|
||||||
|
|
||||||
|
let errorMessage = 'Login failed. Please check your credentials.';
|
||||||
|
|
||||||
|
switch (error.code) {
|
||||||
|
case 'INVALID_CREDENTIALS':
|
||||||
|
errorMessage = 'Invalid email or password.';
|
||||||
|
break;
|
||||||
|
case 'ACCOUNT_LOCKED':
|
||||||
|
errorMessage = 'Your account has been temporarily locked. Please try again later.';
|
||||||
|
break;
|
||||||
|
case 'EMAIL_NOT_VERIFIED':
|
||||||
|
errorMessage = 'Please verify your email address before logging in.';
|
||||||
|
break;
|
||||||
|
case 'TOO_MANY_ATTEMPTS':
|
||||||
|
errorMessage = 'Too many failed attempts. Please try again later.';
|
||||||
|
break;
|
||||||
|
case 'NETWORK_ERROR':
|
||||||
|
errorMessage = 'Network error. Please check your connection.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should lock the account
|
||||||
|
if (this.loginAttempts >= this.maxLoginAttempts) {
|
||||||
|
this.lockAccount();
|
||||||
|
errorMessage = `Too many failed attempts. Account locked for 15 minutes.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snackBar.open(errorMessage, 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
panelClass: ['error-snackbar']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear password field for security
|
||||||
|
this.loginForm.patchValue({ password: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock account temporarily
|
||||||
|
*/
|
||||||
|
private lockAccount(): void {
|
||||||
|
this.isLocked = true;
|
||||||
|
this.lockoutEndTime = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||||
|
|
||||||
|
localStorage.setItem('david_auth_lockout', JSON.stringify({
|
||||||
|
endTime: this.lockoutEndTime.toISOString(),
|
||||||
|
attempts: this.loginAttempts
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.startLockoutTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start lockout countdown timer
|
||||||
|
*/
|
||||||
|
private startLockoutTimer(): void {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (this.lockoutEndTime && new Date() >= this.lockoutEndTime) {
|
||||||
|
this.isLocked = false;
|
||||||
|
this.lockoutEndTime = null;
|
||||||
|
this.loginAttempts = 0;
|
||||||
|
localStorage.removeItem('david_auth_lockout');
|
||||||
|
clearInterval(timer);
|
||||||
|
|
||||||
|
this.snackBar.open('Account unlocked. You can now try logging in again.', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['info-snackbar']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining lockout time in minutes
|
||||||
|
*/
|
||||||
|
getRemainingLockoutTime(): number {
|
||||||
|
if (!this.lockoutEndTime) return 0;
|
||||||
|
const remaining = Math.ceil((this.lockoutEndTime.getTime() - Date.now()) / (1000 * 60));
|
||||||
|
return Math.max(0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle password visibility
|
||||||
|
*/
|
||||||
|
togglePasswordVisibility(): void {
|
||||||
|
this.hidePassword = !this.hidePassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to registration page
|
||||||
|
*/
|
||||||
|
navigateToRegister(): void {
|
||||||
|
this.router.navigate(['/register']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to password reset page
|
||||||
|
*/
|
||||||
|
navigateToPasswordReset(): void {
|
||||||
|
this.router.navigate(['/forgot-password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill form with demo credentials (no automatic login)
|
||||||
|
*/
|
||||||
|
fillDemoCredentials(): void {
|
||||||
|
if (this.isLocked || this.isLoading || !environment.demo.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo credentials from environment configuration
|
||||||
|
const demoCredentials = {
|
||||||
|
email: environment.demo.credentials.email,
|
||||||
|
password: environment.demo.credentials.password,
|
||||||
|
rememberMe: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill the form without submitting
|
||||||
|
this.loginForm.patchValue(demoCredentials);
|
||||||
|
|
||||||
|
// Show info message
|
||||||
|
this.snackBar.open('Demo credentials loaded. Click "Sign In" to login.', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
panelClass: ['info-snackbar']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if demo login is available
|
||||||
|
*/
|
||||||
|
isDemoEnabled(): boolean {
|
||||||
|
return environment.demo.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field error message
|
||||||
|
*/
|
||||||
|
getFieldError(fieldName: string): string {
|
||||||
|
const field = this.loginForm.get(fieldName);
|
||||||
|
if (!field || !field.errors || !field.touched) return '';
|
||||||
|
|
||||||
|
const errors = field.errors;
|
||||||
|
|
||||||
|
if (errors['required']) return `${this.getFieldDisplayName(fieldName)} is required.`;
|
||||||
|
if (errors['email']) return 'Please enter a valid email address.';
|
||||||
|
if (errors['minlength']) return `${this.getFieldDisplayName(fieldName)} must be at least ${errors['minlength'].requiredLength} characters.`;
|
||||||
|
if (errors['maxlength']) return `${this.getFieldDisplayName(fieldName)} must not exceed ${errors['maxlength'].requiredLength} characters.`;
|
||||||
|
|
||||||
|
return 'Invalid input.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for field
|
||||||
|
*/
|
||||||
|
private getFieldDisplayName(fieldName: string): string {
|
||||||
|
const names: { [key: string]: string } = {
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password'
|
||||||
|
};
|
||||||
|
return names[fieldName] || fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if field has error
|
||||||
|
*/
|
||||||
|
hasFieldError(fieldName: string): boolean {
|
||||||
|
const field = this.loginForm.get(fieldName);
|
||||||
|
return !!(field && field.invalid && field.touched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all form fields as touched
|
||||||
|
*/
|
||||||
|
private markFormGroupTouched(): void {
|
||||||
|
Object.keys(this.loginForm.controls).forEach(key => {
|
||||||
|
const control = this.loginForm.get(key);
|
||||||
|
if (control) {
|
||||||
|
control.markAsTouched();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
403
src/app/pages/preview/preview.component.css
Normal file
403
src/app/pages/preview/preview.component.css
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* Resume Preview Component Styles
|
||||||
|
* Professional Angular Resume Builder - Live Resume Preview & Export
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Resume preview page with print layout, export options, and real-time rendering
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Preview Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Action Bar & Controls
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-preview {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 12px var(--color-shadow-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-card {
|
||||||
|
padding: 48px;
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Section */
|
||||||
|
.resume-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-name {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item mat-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Styling */
|
||||||
|
.resume-section {
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title mat-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional Summary */
|
||||||
|
.summary-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Experience Section */
|
||||||
|
.experience-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-item {
|
||||||
|
border-left: 4px solid var(--color-primary-light);
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-description {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements h6 {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.technologies {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.technologies mat-chip {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Education Section */
|
||||||
|
.education-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.education-item {
|
||||||
|
border-left: 4px solid var(--color-success-light);
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.education-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.degree-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.institution-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-success);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.education-description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skills Section */
|
||||||
|
.skills-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-category {
|
||||||
|
background: var(--color-background);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-level {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-progress {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Languages Section */
|
||||||
|
.languages-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-level {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.resume-footer {
|
||||||
|
margin-top: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 16px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.action-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-preview {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-name {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.resume-card {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-header,
|
||||||
|
.education-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-name {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/app/pages/preview/preview.component.html
Normal file
192
src/app/pages/preview/preview.component.html
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<!-- Professional Resume Preview -->
|
||||||
|
<div class="preview-container">
|
||||||
|
<!-- Action Bar -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<button mat-raised-button color="primary" (click)="exportToPDF()">
|
||||||
|
<mat-icon>picture_as_pdf</mat-icon>
|
||||||
|
PDF exportieren
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button (click)="printResume()">
|
||||||
|
<mat-icon>print</mat-icon>
|
||||||
|
Drucken
|
||||||
|
</button>
|
||||||
|
<button mat-button routerLink="/builder">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Lebenslauf bearbeiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resume Content -->
|
||||||
|
<div class="resume-preview" id="resume-content">
|
||||||
|
<mat-card class="resume-card">
|
||||||
|
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="resume-header">
|
||||||
|
<div class="personal-info">
|
||||||
|
<h1 class="full-name">
|
||||||
|
{{ resumeData.personalInfo.firstName }} {{ resumeData.personalInfo.lastName }}
|
||||||
|
</h1>
|
||||||
|
<h2 class="job-title" *ngIf="resumeData.personalInfo.jobTitle">
|
||||||
|
{{ resumeData.personalInfo.jobTitle }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-item" *ngIf="resumeData.personalInfo.email">
|
||||||
|
<mat-icon>email</mat-icon>
|
||||||
|
<span>{{ resumeData.personalInfo.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item" *ngIf="resumeData.personalInfo.phone">
|
||||||
|
<mat-icon>phone</mat-icon>
|
||||||
|
<span>{{ resumeData.personalInfo.phone }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item" *ngIf="resumeData.personalInfo.location">
|
||||||
|
<mat-icon>location_on</mat-icon>
|
||||||
|
<span>{{ resumeData.personalInfo.location }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Professional Summary -->
|
||||||
|
<div class="resume-section" *ngIf="resumeData.personalInfo.summary">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<mat-icon>person</mat-icon>
|
||||||
|
Berufliche Zusammenfassung
|
||||||
|
</h3>
|
||||||
|
<p class="summary-text">{{ resumeData.personalInfo.summary }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Experience Section -->
|
||||||
|
<div class="resume-section" *ngIf="resumeData.experience.length > 0">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<mat-icon>work</mat-icon>
|
||||||
|
Berufserfahrung
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="experience-list">
|
||||||
|
<div class="experience-item" *ngFor="let exp of resumeData.experience">
|
||||||
|
<div class="experience-header">
|
||||||
|
<div class="position-info">
|
||||||
|
<h4 class="position-title">{{ exp.position }}</h4>
|
||||||
|
<h5 class="company-name">{{ exp.company }}</h5>
|
||||||
|
<p class="location" *ngIf="exp.location">{{ exp.location }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="date-range">
|
||||||
|
<span class="date-text">
|
||||||
|
{{ formatDate(exp.startDate) }} -
|
||||||
|
{{ exp.isCurrentPosition ? 'Aktuell' : formatDate(exp.endDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="experience-description" *ngIf="exp.description">
|
||||||
|
{{ exp.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="achievements" *ngIf="exp.achievements && exp.achievements.length > 0">
|
||||||
|
<h6>Wichtige Erfolge:</h6>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let achievement of exp.achievements">{{ achievement }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="technologies" *ngIf="exp.technologies && exp.technologies.length > 0">
|
||||||
|
<mat-chip-listbox>
|
||||||
|
<mat-chip *ngFor="let tech of exp.technologies">{{ tech }}</mat-chip>
|
||||||
|
</mat-chip-listbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-divider *ngIf="resumeData.education.length > 0"></mat-divider>
|
||||||
|
|
||||||
|
<!-- Education Section -->
|
||||||
|
<div class="resume-section" *ngIf="resumeData.education.length > 0">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<mat-icon>school</mat-icon>
|
||||||
|
Education
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="education-list">
|
||||||
|
<div class="education-item" *ngFor="let edu of resumeData.education">
|
||||||
|
<div class="education-header">
|
||||||
|
<div class="degree-info">
|
||||||
|
<h4 class="degree-title">{{ edu.degree }}</h4>
|
||||||
|
<h5 class="institution-name">{{ edu.institution }}</h5>
|
||||||
|
<p class="location" *ngIf="edu.location">{{ edu.location }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="date-range">
|
||||||
|
<span class="date-text">
|
||||||
|
{{ formatDate(edu.startDate) }} - {{ formatDate(edu.endDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="education-details">
|
||||||
|
<p *ngIf="edu.grade" class="grade">Grade: {{ edu.grade }}</p>
|
||||||
|
<p *ngIf="edu.description" class="education-description">{{ edu.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-divider *ngIf="resumeData.skills.length > 0"></mat-divider>
|
||||||
|
|
||||||
|
<!-- Skills Section -->
|
||||||
|
<div class="resume-section" *ngIf="resumeData.skills.length > 0">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<mat-icon>psychology</mat-icon>
|
||||||
|
Skills & Competencies
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="skills-grid">
|
||||||
|
<div class="skill-category" *ngFor="let category of getSkillCategories()">
|
||||||
|
<h6 class="category-title">{{ category.name }}</h6>
|
||||||
|
<div class="skills-in-category">
|
||||||
|
<div class="skill-item" *ngFor="let skill of category.skills">
|
||||||
|
<div class="skill-info">
|
||||||
|
<span class="skill-name">{{ skill.name }}</span>
|
||||||
|
<span class="skill-level">{{ skill.proficiency }}</span>
|
||||||
|
</div>
|
||||||
|
<mat-progress-bar
|
||||||
|
mode="determinate"
|
||||||
|
[value]="getSkillPercentage(skill.proficiencyLevel)"
|
||||||
|
class="skill-progress">
|
||||||
|
</mat-progress-bar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-divider *ngIf="resumeData.languages.length > 0"></mat-divider>
|
||||||
|
|
||||||
|
<!-- Languages Section -->
|
||||||
|
<div class="resume-section" *ngIf="resumeData.languages.length > 0">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<mat-icon>language</mat-icon>
|
||||||
|
Languages
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="languages-list">
|
||||||
|
<div class="language-item" *ngFor="let lang of resumeData.languages">
|
||||||
|
<span class="language-name">{{ lang.name }}</span>
|
||||||
|
<span class="language-level">{{ lang.proficiency }} ({{ lang.level }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="resume-footer">
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<p class="footer-text">
|
||||||
|
Created with Professional Resume Builder by David Valera Melendez | Made in Germany 🇩🇪
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
165
src/app/pages/preview/preview.component.ts
Normal file
165
src/app/pages/preview/preview.component.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Resume Preview Page Component
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
|
||||||
|
import { ResumeData } from '../../models/resume.model';
|
||||||
|
import { ResumeService } from '../../services/resume.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume Preview Component - Professional CV Display
|
||||||
|
* Renders a complete, professional resume preview with print support
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatProgressBarModule
|
||||||
|
],
|
||||||
|
templateUrl: './preview.component.html',
|
||||||
|
styleUrls: ['./preview.component.css']
|
||||||
|
})
|
||||||
|
export class PreviewComponent implements OnInit {
|
||||||
|
resumeData: ResumeData = this.getEmptyResumeData();
|
||||||
|
|
||||||
|
constructor(private resumeService: ResumeService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadResumeData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load resume data from service
|
||||||
|
*/
|
||||||
|
private loadResumeData(): void {
|
||||||
|
const data = this.resumeService.getResumeData();
|
||||||
|
if (data && this.hasContent(data)) {
|
||||||
|
this.resumeData = data;
|
||||||
|
} else {
|
||||||
|
// Load sample data if no real data exists
|
||||||
|
this.resumeData = this.resumeService.getSampleResumeData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if resume has any meaningful content
|
||||||
|
*/
|
||||||
|
private hasContent(data: ResumeData): boolean {
|
||||||
|
return !!(
|
||||||
|
data.personalInfo.firstName ||
|
||||||
|
data.personalInfo.lastName ||
|
||||||
|
data.experience.length > 0 ||
|
||||||
|
data.education.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date string for display
|
||||||
|
*/
|
||||||
|
formatDate(dateString: string | undefined): string {
|
||||||
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skill categories with grouped skills
|
||||||
|
*/
|
||||||
|
getSkillCategories(): Array<{name: string, skills: any[]}> {
|
||||||
|
const categories: {[key: string]: any[]} = {};
|
||||||
|
|
||||||
|
this.resumeData.skills.forEach(skill => {
|
||||||
|
const category = skill.category || 'other';
|
||||||
|
if (!categories[category]) {
|
||||||
|
categories[category] = [];
|
||||||
|
}
|
||||||
|
categories[category].push(skill);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(categories).map(key => ({
|
||||||
|
name: key.charAt(0).toUpperCase() + key.slice(1),
|
||||||
|
skills: categories[key]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert proficiency level to percentage
|
||||||
|
*/
|
||||||
|
getSkillPercentage(level: number | undefined): number {
|
||||||
|
if (!level) return 50;
|
||||||
|
return Math.min(100, Math.max(0, (level / 10) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export resume to PDF
|
||||||
|
*/
|
||||||
|
exportToPDF(): void {
|
||||||
|
// TODO: Implementation would use libraries like jsPDF with html2canvas
|
||||||
|
|
||||||
|
// For now, use browser's print functionality
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print resume
|
||||||
|
*/
|
||||||
|
printResume(): void {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get empty resume data structure
|
||||||
|
*/
|
||||||
|
private getEmptyResumeData(): ResumeData {
|
||||||
|
return {
|
||||||
|
personalInfo: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
jobTitle: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
city: '',
|
||||||
|
country: '',
|
||||||
|
location: '',
|
||||||
|
summary: ''
|
||||||
|
},
|
||||||
|
experience: [],
|
||||||
|
education: [],
|
||||||
|
skills: [],
|
||||||
|
languages: [],
|
||||||
|
certifications: [],
|
||||||
|
projects: [],
|
||||||
|
awards: [],
|
||||||
|
references: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/app/pages/resume-builder/resume-builder.component.css
Normal file
172
src/app/pages/resume-builder/resume-builder.component.css
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Resume Builder Component Styles
|
||||||
|
* Professional Angular Resume Builder - Interactive Resume Creation Interface
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Main resume building interface with form sections, drag-and-drop, and real-time preview
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Builder Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.builder-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Builder Header
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.builder-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-header h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-header p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-card {
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-stepper {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-form {
|
||||||
|
padding: 24px 0;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-form h3 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.experience-item,
|
||||||
|
.education-item,
|
||||||
|
.skill-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-info h4 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-category {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-level {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stepper-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material Stepper Custom Styles */
|
||||||
|
::ng-deep .mat-stepper-horizontal {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-step-header .mat-step-icon {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-step-header .mat-step-icon-selected {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
214
src/app/pages/resume-builder/resume-builder.component.html
Normal file
214
src/app/pages/resume-builder/resume-builder.component.html
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<!-- Professional Resume Builder -->
|
||||||
|
<div class="builder-container">
|
||||||
|
<div class="builder-header">
|
||||||
|
<h1>
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Professional Lebenslauf Generator
|
||||||
|
</h1>
|
||||||
|
<p>Erstellen Sie Ihren professionellen Lebenslauf Schritt für Schritt</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-card class="stepper-card">
|
||||||
|
<mat-stepper [linear]="true" #stepper class="resume-stepper">
|
||||||
|
|
||||||
|
<!-- Step 1: Personal Information -->
|
||||||
|
<mat-step [stepControl]="personalInfoForm" label="Persönliche Info" state="person">
|
||||||
|
<ng-template matStepLabel>Persönliche Informationen</ng-template>
|
||||||
|
<form [formGroup]="personalInfoForm" class="step-form">
|
||||||
|
<h3>Persönliche Informationen</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Vorname</mat-label>
|
||||||
|
<input matInput formControlName="firstName" required>
|
||||||
|
<mat-icon matSuffix>person</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nachname</mat-label>
|
||||||
|
<input matInput formControlName="lastName" required>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Berufsbezeichnung</mat-label>
|
||||||
|
<input matInput formControlName="jobTitle"
|
||||||
|
placeholder="z.B. Senior Frontend Developer">
|
||||||
|
<mat-icon matSuffix>work</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>E-Mail</mat-label>
|
||||||
|
<input matInput type="email" formControlName="email" required>
|
||||||
|
<mat-icon matSuffix>email</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Telefon</mat-label>
|
||||||
|
<input matInput type="tel" formControlName="phone" required>
|
||||||
|
<mat-icon matSuffix>phone</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Stadt</mat-label>
|
||||||
|
<input matInput formControlName="city" required>
|
||||||
|
<mat-icon matSuffix>location_city</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Land</mat-label>
|
||||||
|
<input matInput formControlName="country">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Berufliche Zusammenfassung</mat-label>
|
||||||
|
<textarea matInput formControlName="summary"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Kurze berufliche Zusammenfassung..."></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button mat-raised-button color="primary" matStepperNext>
|
||||||
|
Weiter
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-step>
|
||||||
|
|
||||||
|
<!-- Step 2: Experience -->
|
||||||
|
<mat-step [stepControl]="experienceForm" label="Berufserfahrung" state="work">
|
||||||
|
<ng-template matStepLabel>Berufserfahrung</ng-template>
|
||||||
|
<div class="step-form">
|
||||||
|
<h3>Berufserfahrung</h3>
|
||||||
|
<p>Fügen Sie Ihre Berufserfahrung hinzu, beginnend mit Ihrer aktuellsten Position.</p>
|
||||||
|
|
||||||
|
<div *ngFor="let exp of experiences; let i = index" class="experience-item">
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ exp.position || 'Neue Position' }}</mat-card-title>
|
||||||
|
<mat-card-subtitle>{{ exp.company }}</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-actions align="end">
|
||||||
|
<button mat-icon-button (click)="editExperience(i)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button (click)="removeExperience(i)" color="warn">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-stroked-button (click)="addExperience()" class="add-button">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Berufserfahrung hinzufügen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button mat-button matStepperPrevious>
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" matStepperNext>
|
||||||
|
Weiter
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-step>
|
||||||
|
|
||||||
|
<!-- Step 3: Education -->
|
||||||
|
<mat-step [stepControl]="educationForm" label="Bildung" state="school">
|
||||||
|
<ng-template matStepLabel>Bildung</ng-template>
|
||||||
|
<div class="step-form">
|
||||||
|
<h3>Bildungshintergrund</h3>
|
||||||
|
<p>Fügen Sie Ihre Bildungsqualifikationen hinzu.</p>
|
||||||
|
|
||||||
|
<div *ngFor="let edu of education; let i = index" class="education-item">
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ edu.degree || 'Neuer Abschluss' }}</mat-card-title>
|
||||||
|
<mat-card-subtitle>{{ edu.institution }}</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-actions align="end">
|
||||||
|
<button mat-icon-button (click)="editEducation(i)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button (click)="removeEducation(i)" color="warn">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-stroked-button (click)="addEducation()" class="add-button">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Bildung hinzufügen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button mat-button matStepperPrevious>
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" matStepperNext>
|
||||||
|
Next
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-step>
|
||||||
|
|
||||||
|
<!-- Step 4: Skills -->
|
||||||
|
<mat-step [stepControl]="skillsForm" label="Fähigkeiten" state="psychology">
|
||||||
|
<ng-template matStepLabel>Fähigkeiten & Kompetenzen</ng-template>
|
||||||
|
<div class="step-form">
|
||||||
|
<h3>Fähigkeiten & Kompetenzen</h3>
|
||||||
|
<p>Fügen Sie Ihre technischen und sozialen Kompetenzen hinzu.</p>
|
||||||
|
|
||||||
|
<div *ngFor="let skill of skills; let i = index" class="skill-item">
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="skill-content">
|
||||||
|
<div class="skill-info">
|
||||||
|
<h4>{{ skill.name }}</h4>
|
||||||
|
<span class="skill-category">{{ skill.category }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="skill-level">
|
||||||
|
<span>{{ skill.proficiency }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions align="end">
|
||||||
|
<button mat-icon-button (click)="editSkill(i)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button (click)="removeSkill(i)" color="warn">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-stroked-button (click)="addSkill()" class="add-button">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Fähigkeit hinzufügen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button mat-button matStepperPrevious>
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="accent" (click)="saveResume()">
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
Lebenslauf speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-step>
|
||||||
|
|
||||||
|
</mat-stepper>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
275
src/app/pages/resume-builder/resume-builder.component.ts
Normal file
275
src/app/pages/resume-builder/resume-builder.component.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Resume Builder Page Component
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatStepperModule } from '@angular/material/stepper';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
|
||||||
|
import { ResumeData, PersonalInfo, Experience, Education, Skill } from '../../models/resume.model';
|
||||||
|
import { ResumeService } from '../../services/resume.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume Builder Component with Material Stepper
|
||||||
|
* Professional multi-step form for creating resumes
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-resume-builder',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatStepperModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatDividerModule
|
||||||
|
],
|
||||||
|
templateUrl: './resume-builder.component.html',
|
||||||
|
styleUrls: ['./resume-builder.component.css']
|
||||||
|
})
|
||||||
|
export class ResumeBuilderComponent implements OnInit {
|
||||||
|
// Form Groups
|
||||||
|
personalInfoForm!: FormGroup;
|
||||||
|
experienceForm!: FormGroup;
|
||||||
|
educationForm!: FormGroup;
|
||||||
|
skillsForm!: FormGroup;
|
||||||
|
|
||||||
|
// Resume Data
|
||||||
|
resumeData: ResumeData = this.getEmptyResumeData();
|
||||||
|
experiences: Experience[] = [];
|
||||||
|
education: Education[] = [];
|
||||||
|
skills: Skill[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private resumeService: ResumeService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initializeForms();
|
||||||
|
this.loadExistingData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all form groups with validation
|
||||||
|
*/
|
||||||
|
private initializeForms(): void {
|
||||||
|
this.personalInfoForm = this.formBuilder.group({
|
||||||
|
firstName: ['', Validators.required],
|
||||||
|
lastName: ['', Validators.required],
|
||||||
|
jobTitle: [''],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
phone: ['', Validators.required],
|
||||||
|
city: ['', Validators.required],
|
||||||
|
country: [''],
|
||||||
|
summary: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
this.experienceForm = this.formBuilder.group({
|
||||||
|
// Dynamic form controls will be added for experiences
|
||||||
|
});
|
||||||
|
|
||||||
|
this.educationForm = this.formBuilder.group({
|
||||||
|
// Dynamic form controls will be added for education
|
||||||
|
});
|
||||||
|
|
||||||
|
this.skillsForm = this.formBuilder.group({
|
||||||
|
// Dynamic form controls will be added for skills
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing resume data from service
|
||||||
|
*/
|
||||||
|
private loadExistingData(): void {
|
||||||
|
const existingData = this.resumeService.getResumeData();
|
||||||
|
|
||||||
|
if (existingData.personalInfo) {
|
||||||
|
this.personalInfoForm.patchValue(existingData.personalInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.experiences = existingData.experience || [];
|
||||||
|
this.education = existingData.education || [];
|
||||||
|
this.skills = existingData.skills || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new experience entry
|
||||||
|
*/
|
||||||
|
addExperience(): void {
|
||||||
|
const newExperience: Experience = {
|
||||||
|
id: this.generateId(),
|
||||||
|
company: '',
|
||||||
|
position: '',
|
||||||
|
location: '',
|
||||||
|
startDate: new Date().toISOString().split('T')[0],
|
||||||
|
endDate: undefined,
|
||||||
|
isCurrentPosition: false,
|
||||||
|
description: ''
|
||||||
|
};
|
||||||
|
this.experiences.push(newExperience);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit experience entry
|
||||||
|
* @param index Index of experience to edit
|
||||||
|
*/
|
||||||
|
editExperience(index: number): void {
|
||||||
|
// TODO: Implementation for editing experience
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove experience entry
|
||||||
|
* @param index Index of experience to remove
|
||||||
|
*/
|
||||||
|
removeExperience(index: number): void {
|
||||||
|
if (index >= 0 && index < this.experiences.length) {
|
||||||
|
this.experiences.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new education entry
|
||||||
|
*/
|
||||||
|
addEducation(): void {
|
||||||
|
const newEducation: Education = {
|
||||||
|
id: this.generateId(),
|
||||||
|
institution: '',
|
||||||
|
degree: '',
|
||||||
|
location: '',
|
||||||
|
startDate: new Date().toISOString().split('T')[0],
|
||||||
|
endDate: undefined
|
||||||
|
};
|
||||||
|
this.education.push(newEducation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit education entry
|
||||||
|
* @param index Index of education to edit
|
||||||
|
*/
|
||||||
|
editEducation(index: number): void {
|
||||||
|
// TODO: Implementation for editing education
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove education entry
|
||||||
|
* @param index Index of education to remove
|
||||||
|
*/
|
||||||
|
removeEducation(index: number): void {
|
||||||
|
if (index >= 0 && index < this.education.length) {
|
||||||
|
this.education.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new skill entry
|
||||||
|
*/
|
||||||
|
addSkill(): void {
|
||||||
|
const newSkill: Skill = {
|
||||||
|
id: this.generateId(),
|
||||||
|
name: '',
|
||||||
|
category: 'technical',
|
||||||
|
proficiency: 'intermediate'
|
||||||
|
};
|
||||||
|
this.skills.push(newSkill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit skill entry
|
||||||
|
* @param index Index of skill to edit
|
||||||
|
*/
|
||||||
|
editSkill(index: number): void {
|
||||||
|
// TODO: Implementation for editing skill
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove skill entry
|
||||||
|
* @param index Index of skill to remove
|
||||||
|
*/
|
||||||
|
removeSkill(index: number): void {
|
||||||
|
if (index >= 0 && index < this.skills.length) {
|
||||||
|
this.skills.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save complete resume data
|
||||||
|
*/
|
||||||
|
saveResume(): void {
|
||||||
|
const personalInfo: PersonalInfo = this.personalInfoForm.value;
|
||||||
|
|
||||||
|
const resumeData: ResumeData = {
|
||||||
|
personalInfo,
|
||||||
|
experience: this.experiences,
|
||||||
|
education: this.education,
|
||||||
|
skills: this.skills,
|
||||||
|
languages: [],
|
||||||
|
certifications: [],
|
||||||
|
projects: [],
|
||||||
|
awards: [],
|
||||||
|
references: [],
|
||||||
|
createdAt: this.resumeData.createdAt || new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.resumeService.saveResumeData(resumeData);
|
||||||
|
// Resume saved successfully
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get empty resume data structure
|
||||||
|
*/
|
||||||
|
private getEmptyResumeData(): ResumeData {
|
||||||
|
return {
|
||||||
|
personalInfo: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
jobTitle: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
city: '',
|
||||||
|
country: '',
|
||||||
|
location: '',
|
||||||
|
summary: ''
|
||||||
|
},
|
||||||
|
experience: [],
|
||||||
|
education: [],
|
||||||
|
skills: [],
|
||||||
|
languages: [],
|
||||||
|
certifications: [],
|
||||||
|
projects: [],
|
||||||
|
awards: [],
|
||||||
|
references: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique ID for resume
|
||||||
|
*/
|
||||||
|
private generateId(): string {
|
||||||
|
return 'resume_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/app/pages/settings/settings.component.css
Normal file
223
src/app/pages/settings/settings.component.css
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Settings Page Component Styles
|
||||||
|
* Professional Angular Resume Builder - User Settings & Preferences
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description User settings page for theme management, profile preferences, and account options
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Settings Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Settings Header
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card mat-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group.danger-zone {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
background: var(--color-error-light);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: var(--color-error) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-info {
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-item {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list li {
|
||||||
|
padding: 4px 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list li::before {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-container {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-actions {
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-list {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/app/pages/settings/settings.component.html
Normal file
173
src/app/pages/settings/settings.component.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<!-- Settings & Preferences Page -->
|
||||||
|
<div class="settings-container">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h1>
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
Einstellungen & Präferenzen
|
||||||
|
</h1>
|
||||||
|
<p>Verwalten Sie Ihre Lebenslauf-Generator-Einstellungen und Daten</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
|
||||||
|
<!-- Application Settings -->
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>tune</mat-icon>
|
||||||
|
Anwendungseinstellungen
|
||||||
|
</mat-card-title>
|
||||||
|
<mat-card-subtitle>Konfigurieren Sie Ihre Lebenslauf-Generator-Einstellungen</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<form [formGroup]="settingsForm" class="settings-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Anzeigeeinstellungen</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-slide-toggle formControlName="darkMode">
|
||||||
|
Dunkler Modus
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-slide-toggle formControlName="showTooltips">
|
||||||
|
Hilfreiche Tipps anzeigen
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Export-Einstellungen</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Standard-Papierformat</mat-label>
|
||||||
|
<mat-select formControlName="defaultPaperSize">
|
||||||
|
<mat-option value="A4">A4 (Europäischer Standard)</mat-option>
|
||||||
|
<mat-option value="Letter">Letter (US-Standard)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button mat-raised-button color="primary" (click)="saveSettings()">
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Data Management -->
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>storage</mat-icon>
|
||||||
|
Data Management
|
||||||
|
</mat-card-title>
|
||||||
|
<mat-card-subtitle>Import, export, and manage your resume data</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="data-actions">
|
||||||
|
|
||||||
|
<div class="action-group">
|
||||||
|
<h3>Export Data</h3>
|
||||||
|
<p>Download your resume data as a backup file</p>
|
||||||
|
<button mat-stroked-button (click)="exportData()">
|
||||||
|
<mat-icon>download</mat-icon>
|
||||||
|
Export Resume Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-group">
|
||||||
|
<h3>Import Data</h3>
|
||||||
|
<p>Upload a previously exported resume data file</p>
|
||||||
|
<input #fileInput type="file" accept=".json" (change)="importData($event)" hidden>
|
||||||
|
<button mat-stroked-button (click)="fileInput.click()">
|
||||||
|
<mat-icon>upload</mat-icon>
|
||||||
|
Import Resume Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-group">
|
||||||
|
<h3>Sample Data</h3>
|
||||||
|
<p>Load sample resume data to see how the builder works</p>
|
||||||
|
<button mat-stroked-button color="accent" (click)="loadSampleData()">
|
||||||
|
<mat-icon>preview</mat-icon>
|
||||||
|
Load Sample Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-group danger-zone">
|
||||||
|
<h3>Reset Data</h3>
|
||||||
|
<p class="warning-text">⚠️ This will permanently delete all your resume data</p>
|
||||||
|
<button mat-stroked-button color="warn" (click)="clearAllData()">
|
||||||
|
<mat-icon>delete_forever</mat-icon>
|
||||||
|
Clear All Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<mat-card class="settings-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
About
|
||||||
|
</mat-card-title>
|
||||||
|
<mat-card-subtitle>Professional Resume Builder Information</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-section">
|
||||||
|
<h3>Professional Resume Builder</h3>
|
||||||
|
<p>Version 1.0.0</p>
|
||||||
|
<p>
|
||||||
|
A modern, professional resume builder application built with Angular 17,
|
||||||
|
TypeScript, and Angular Material UI. Designed to help professionals create
|
||||||
|
outstanding resumes with ease.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3>Author Information</h3>
|
||||||
|
<div class="author-info">
|
||||||
|
<p><strong>Created by:</strong> David Valera Melendez</p>
|
||||||
|
<p><strong>Email:</strong> david@valera-melendez.de</p>
|
||||||
|
<p><strong>Location:</strong> Made in Germany 🇩🇪</p>
|
||||||
|
<p><strong>Created:</strong> August 8, 2025</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3>Technology Stack</h3>
|
||||||
|
<div class="tech-list">
|
||||||
|
<span class="tech-item">Angular 17</span>
|
||||||
|
<span class="tech-item">TypeScript</span>
|
||||||
|
<span class="tech-item">Angular Material</span>
|
||||||
|
<span class="tech-item">SCSS</span>
|
||||||
|
<span class="tech-item">RxJS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3>Features</h3>
|
||||||
|
<ul class="features-list">
|
||||||
|
<li>Professional Material Design UI</li>
|
||||||
|
<li>Step-by-step resume builder</li>
|
||||||
|
<li>Real-time preview</li>
|
||||||
|
<li>PDF export functionality</li>
|
||||||
|
<li>Data import/export</li>
|
||||||
|
<li>Responsive design</li>
|
||||||
|
<li>Local data storage</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
165
src/app/pages/settings/settings.component.ts
Normal file
165
src/app/pages/settings/settings.component.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Settings Page Component
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||||
|
|
||||||
|
import { ResumeService } from '../../services/resume.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings Component - Application configuration and data management
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatDialogModule
|
||||||
|
],
|
||||||
|
templateUrl: './settings.component.html',
|
||||||
|
styleUrls: ['./settings.component.css']
|
||||||
|
})
|
||||||
|
export class SettingsComponent implements OnInit {
|
||||||
|
settingsForm!: FormGroup;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private resumeService: ResumeService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private dialog: MatDialog
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initializeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize settings form with default values
|
||||||
|
*/
|
||||||
|
private initializeForm(): void {
|
||||||
|
this.settingsForm = this.formBuilder.group({
|
||||||
|
darkMode: [false],
|
||||||
|
showTooltips: [true],
|
||||||
|
defaultPaperSize: ['A4']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save application settings
|
||||||
|
*/
|
||||||
|
saveSettings(): void {
|
||||||
|
const settings = this.settingsForm.value;
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem('resumeBuilderSettings', JSON.stringify(settings));
|
||||||
|
|
||||||
|
this.snackBar.open('Settings saved successfully!', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export resume data as JSON file
|
||||||
|
*/
|
||||||
|
exportData(): void {
|
||||||
|
const resumeData = this.resumeService.getResumeData();
|
||||||
|
const dataStr = JSON.stringify(resumeData, null, 2);
|
||||||
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(dataBlob);
|
||||||
|
link.download = 'resume-data.json';
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
this.snackBar.open('Resume data exported successfully!', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import resume data from JSON file
|
||||||
|
*/
|
||||||
|
importData(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.target?.result as string);
|
||||||
|
this.resumeService.saveResumeData(data);
|
||||||
|
|
||||||
|
this.snackBar.open('Resume data imported successfully!', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.snackBar.open('Error importing data. Please check file format.', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load sample resume data
|
||||||
|
*/
|
||||||
|
loadSampleData(): void {
|
||||||
|
const sampleData = this.resumeService.getSampleResumeData();
|
||||||
|
this.resumeService.saveResumeData(sampleData);
|
||||||
|
|
||||||
|
this.snackBar.open('Sample data loaded successfully!', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all resume data
|
||||||
|
*/
|
||||||
|
clearAllData(): void {
|
||||||
|
if (confirm('⚠️ Are you sure you want to delete all resume data? This action cannot be undone.')) {
|
||||||
|
this.resumeService.clearResumeData();
|
||||||
|
|
||||||
|
this.snackBar.open('All data cleared successfully!', 'Close', {
|
||||||
|
duration: 3000,
|
||||||
|
horizontalPosition: 'end',
|
||||||
|
verticalPosition: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/app/pages/templates/templates.component.css
Normal file
161
src/app/pages/templates/templates.component.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Templates Page Component Styles
|
||||||
|
* Professional Angular Resume Builder - Resume Template Gallery
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
* @description Template selection page with preview cards, filtering, and template management
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Templates Container & Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.templates-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Templates Header
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.templates-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-header h1 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-header p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-grid {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 25px var(--color-shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card:hover .preview-image {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--color-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card:hover .preview-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-info h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-info p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-tags {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-actions {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.templates-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-grid mat-grid-list {
|
||||||
|
columns: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--color-overlay-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.templates-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-header p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/pages/templates/templates.component.html
Normal file
46
src/app/pages/templates/templates.component.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="templates-container">
|
||||||
|
<div class="templates-header">
|
||||||
|
<h1>Professionelle Lebenslauf-Vorlagen</h1>
|
||||||
|
<p>Wählen Sie aus unserer Sammlung professionell gestalteter Lebenslauf-Vorlagen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="templates-grid">
|
||||||
|
<mat-grid-list cols="2" rowHeight="400px" gutterSize="24px">
|
||||||
|
<mat-grid-tile *ngFor="let template of templates">
|
||||||
|
<mat-card class="template-card">
|
||||||
|
<div class="template-preview">
|
||||||
|
<img [src]="template.preview" [alt]="template.name" class="preview-image">
|
||||||
|
<div class="preview-overlay">
|
||||||
|
<button mat-raised-button color="primary" (click)="selectTemplate(template.id)">
|
||||||
|
<mat-icon>brush</mat-icon>
|
||||||
|
Vorlage verwenden
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button (click)="previewTemplate(template.id)" class="ml-2">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
Vorschau
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-card-content class="template-info">
|
||||||
|
<h3>{{ template.name }}</h3>
|
||||||
|
<p>{{ template.description }}</p>
|
||||||
|
|
||||||
|
<div class="template-tags">
|
||||||
|
<mat-chip-set>
|
||||||
|
<mat-chip *ngFor="let tag of template.tags">{{ tag }}</mat-chip>
|
||||||
|
</mat-chip-set>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</mat-grid-tile>
|
||||||
|
</mat-grid-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="templates-actions">
|
||||||
|
<button mat-raised-button color="primary" routerLink="/builder">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Von Grund auf beginnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
89
src/app/pages/templates/templates.component.ts
Normal file
89
src/app/pages/templates/templates.component.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Templates Component
|
||||||
|
* Professional resume templates showcase and selection
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatGridListModule } from '@angular/material/grid-list';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-templates',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatGridListModule,
|
||||||
|
MatChipsModule
|
||||||
|
],
|
||||||
|
templateUrl: './templates.component.html',
|
||||||
|
styleUrls: ['./templates.component.css']
|
||||||
|
})
|
||||||
|
export class TemplatesComponent implements OnInit {
|
||||||
|
templates = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Professional Classic',
|
||||||
|
description: 'Clean and professional design perfect for corporate environments',
|
||||||
|
preview: '/assets/templates/professional-classic.png',
|
||||||
|
category: 'Professional',
|
||||||
|
tags: ['Corporate', 'Clean', 'Modern']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Creative Modern',
|
||||||
|
description: 'Bold and creative design for creative professionals',
|
||||||
|
preview: '/assets/templates/creative-modern.png',
|
||||||
|
category: 'Creative',
|
||||||
|
tags: ['Creative', 'Bold', 'Artistic']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Executive Minimalist',
|
||||||
|
description: 'Elegant minimalist design for executive positions',
|
||||||
|
preview: '/assets/templates/executive-minimalist.png',
|
||||||
|
category: 'Executive',
|
||||||
|
tags: ['Minimalist', 'Executive', 'Elegant']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Tech Innovator',
|
||||||
|
description: 'Modern tech-focused design for IT professionals',
|
||||||
|
preview: '/assets/templates/tech-innovator.png',
|
||||||
|
category: 'Technology',
|
||||||
|
tags: ['Tech', 'Innovation', 'Modern']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Initialize component
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a template and navigate to builder
|
||||||
|
*/
|
||||||
|
selectTemplate(templateId: number): void {
|
||||||
|
// TODO: Navigate to builder with selected template
|
||||||
|
// Implementation would go here
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview template in full screen
|
||||||
|
*/
|
||||||
|
previewTemplate(templateId: number): void {
|
||||||
|
// TODO: Open template preview
|
||||||
|
// Implementation would go here
|
||||||
|
}
|
||||||
|
}
|
||||||
667
src/app/services/auth.service.ts
Normal file
667
src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Service
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* ✅ PROFESSIONAL IMPLEMENTATION HIGHLIGHTS:
|
||||||
|
* • Centralized API endpoints via ApiEndpoints utility
|
||||||
|
* • Environment-based configuration (dev/prod)
|
||||||
|
* • Consistent error handling and retries
|
||||||
|
* • Professional code organization and separation of concerns
|
||||||
|
* • Type-safe endpoint management
|
||||||
|
* • Scalable architecture for future endpoints
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, Observable, throwError, timer, of } from 'rxjs';
|
||||||
|
import { map, catchError, switchMap, tap, retry, timeout } from 'rxjs/operators';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import { ApiEndpoints } from '../utils';
|
||||||
|
import { LoadingService } from './loading.service';
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
import { DeviceFingerprintService } from './device-fingerprint.service';
|
||||||
|
import {
|
||||||
|
LoginCredentials,
|
||||||
|
RegisterData,
|
||||||
|
AuthResponse,
|
||||||
|
AuthUser,
|
||||||
|
AuthTokens,
|
||||||
|
PasswordResetRequest,
|
||||||
|
PasswordResetConfirmation,
|
||||||
|
AuthError,
|
||||||
|
JwtPayload,
|
||||||
|
SessionInfo,
|
||||||
|
TwoFactorSetup,
|
||||||
|
TwoFactorVerification,
|
||||||
|
DeviceVerificationResponse,
|
||||||
|
TwoFactorInitiationResponse
|
||||||
|
} from '../models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enterprise-grade Authentication Service
|
||||||
|
* Handles JWT authentication, session management, and security features
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
// Configuration Constants
|
||||||
|
private readonly TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
|
private readonly MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
private readonly REQUEST_TIMEOUT = 30000; // 30 seconds
|
||||||
|
|
||||||
|
// Local Storage Keys
|
||||||
|
private readonly ACCESS_TOKEN_KEY = 'david_auth_access_token';
|
||||||
|
private readonly REFRESH_TOKEN_KEY = 'david_auth_refresh_token';
|
||||||
|
private readonly USER_DATA_KEY = 'david_auth_user_data';
|
||||||
|
private readonly REMEMBER_ME_KEY = 'david_auth_remember_me';
|
||||||
|
|
||||||
|
// Reactive State Management
|
||||||
|
private currentUserSubject = new BehaviorSubject<AuthUser | null>(null);
|
||||||
|
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
private isLoadingSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
private deviceVerificationSubject = new BehaviorSubject<DeviceVerificationResponse | null>(null);
|
||||||
|
private requiresTwoFactorSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
private tokenRefreshTimer: any;
|
||||||
|
|
||||||
|
// Public Observables
|
||||||
|
public readonly currentUser$ = this.currentUserSubject.asObservable();
|
||||||
|
public readonly isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
|
||||||
|
public readonly isLoading$ = this.isLoadingSubject.asObservable();
|
||||||
|
public readonly deviceVerification$ = this.deviceVerificationSubject.asObservable();
|
||||||
|
public readonly requiresTwoFactor$ = this.requiresTwoFactorSubject.asObservable();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
private router: Router,
|
||||||
|
private loadingService: LoadingService,
|
||||||
|
private encryptionService: EncryptionService,
|
||||||
|
private deviceFingerprintService: DeviceFingerprintService
|
||||||
|
) {
|
||||||
|
this.initializeAuthState();
|
||||||
|
this.setupTokenRefreshTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize authentication state from stored tokens
|
||||||
|
*/
|
||||||
|
private initializeAuthState(): void {
|
||||||
|
const accessToken = this.getAccessToken();
|
||||||
|
const userData = this.getUserData();
|
||||||
|
|
||||||
|
if (accessToken && this.isTokenValid(accessToken)) {
|
||||||
|
// Set temporary auth state with stored data
|
||||||
|
this.isAuthenticatedSubject.next(true);
|
||||||
|
if (userData) {
|
||||||
|
this.currentUserSubject.next(userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh profile data from backend
|
||||||
|
this.fetchUserProfile().subscribe({
|
||||||
|
next: (user) => {
|
||||||
|
// Profile loaded successfully
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
// Keep using stored data if backend request fails
|
||||||
|
if (!userData) {
|
||||||
|
this.clearAuthState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scheduleTokenRefresh();
|
||||||
|
} else {
|
||||||
|
this.clearAuthState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public method to refresh authentication state
|
||||||
|
* Can be called after manual token storage to update auth state
|
||||||
|
*/
|
||||||
|
public refreshAuthState(): void {
|
||||||
|
this.initializeAuthState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user profile from backend (internal method)
|
||||||
|
*/
|
||||||
|
private fetchUserProfile(): Observable<AuthUser> {
|
||||||
|
return this.getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User login with integrated device fingerprint verification and 2FA support
|
||||||
|
*/
|
||||||
|
login(credentials: LoginCredentials): Observable<AuthResponse> {
|
||||||
|
// Start both global and form loading
|
||||||
|
this.loadingService.startGlobalLoading();
|
||||||
|
this.loadingService.startFormLoading('login-form');
|
||||||
|
this.isLoadingSubject.next(true);
|
||||||
|
|
||||||
|
// Generate device fingerprint
|
||||||
|
const deviceFingerprint = this.deviceFingerprintService.generateFingerprint();
|
||||||
|
|
||||||
|
// Create secure encrypted payload
|
||||||
|
const securePayload = this.encryptionService.createSecurePayload(credentials.password);
|
||||||
|
|
||||||
|
// Prepare login payload with encrypted password and device fingerprint
|
||||||
|
const loginPayload = {
|
||||||
|
email: credentials.email,
|
||||||
|
deviceFingerprint: {
|
||||||
|
userAgent: deviceFingerprint.userAgent,
|
||||||
|
acceptLanguage: deviceFingerprint.acceptLanguage,
|
||||||
|
screenResolution: deviceFingerprint.screenResolution,
|
||||||
|
timezone: deviceFingerprint.timezone,
|
||||||
|
platform: deviceFingerprint.platform,
|
||||||
|
fontsHash: deviceFingerprint.fontsHash,
|
||||||
|
canvasFingerprint: deviceFingerprint.canvasFingerprint,
|
||||||
|
webglFingerprint: deviceFingerprint.webglFingerprint,
|
||||||
|
cookieEnabled: deviceFingerprint.cookieEnabled,
|
||||||
|
doNotTrack: deviceFingerprint.doNotTrack,
|
||||||
|
touchSupport: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
|
||||||
|
colorDepth: screen.colorDepth || 24,
|
||||||
|
},
|
||||||
|
...securePayload
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.post<AuthResponse>(ApiEndpoints.AUTH.LOGIN, loginPayload)
|
||||||
|
.pipe(
|
||||||
|
timeout(this.REQUEST_TIMEOUT),
|
||||||
|
retry(this.MAX_RETRY_ATTEMPTS),
|
||||||
|
tap(response => {
|
||||||
|
if (response.requiresTwoFactor) {
|
||||||
|
// 2FA is required - don't set tokens yet
|
||||||
|
this.requiresTwoFactorSubject.next(true);
|
||||||
|
} else {
|
||||||
|
// Login successful - handle normal authentication
|
||||||
|
this.handleSuccessfulAuth(response, credentials.rememberMe);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError(error => this.handleAuthError(error)),
|
||||||
|
tap(() => this.stopLoading())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceed with normal login after device verification
|
||||||
|
*/
|
||||||
|
private proceedWithLogin(credentials: LoginCredentials): Observable<AuthResponse> {
|
||||||
|
// Create secure encrypted payload
|
||||||
|
const securePayload = this.encryptionService.createSecurePayload(credentials.password);
|
||||||
|
|
||||||
|
// Prepare login payload with encrypted password
|
||||||
|
const loginPayload = {
|
||||||
|
email: credentials.email,
|
||||||
|
...securePayload
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.post<AuthResponse>(ApiEndpoints.AUTH.LOGIN, loginPayload)
|
||||||
|
.pipe(
|
||||||
|
timeout(this.REQUEST_TIMEOUT),
|
||||||
|
retry(this.MAX_RETRY_ATTEMPTS),
|
||||||
|
tap(response => this.handleSuccessfulAuth(response, credentials.rememberMe)),
|
||||||
|
catchError(error => this.handleAuthError(error)),
|
||||||
|
tap(() => this.stopLoading())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate 2FA for device registration
|
||||||
|
*/
|
||||||
|
initiateTwoFactor(userId: number, deviceName?: string): Observable<TwoFactorInitiationResponse> {
|
||||||
|
return this.deviceFingerprintService.initiateTwoFactor(userId, deviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify 2FA code and complete device registration
|
||||||
|
*/
|
||||||
|
verifyTwoFactor(code: string, verificationId: string, userId: number, tempToken?: string): Observable<any>;
|
||||||
|
verifyTwoFactor(verification: TwoFactorVerification): Observable<AuthResponse>;
|
||||||
|
verifyTwoFactor(
|
||||||
|
codeOrVerification: string | TwoFactorVerification,
|
||||||
|
verificationId?: string,
|
||||||
|
userId?: number,
|
||||||
|
tempToken?: string
|
||||||
|
): Observable<any> {
|
||||||
|
if (typeof codeOrVerification === 'string' && verificationId && userId) {
|
||||||
|
// New device registration 2FA
|
||||||
|
return this.deviceFingerprintService.verifyTwoFactor(codeOrVerification, verificationId, userId, tempToken)
|
||||||
|
.pipe(
|
||||||
|
tap(response => {
|
||||||
|
if (response.success) {
|
||||||
|
// Device is now trusted, clear 2FA requirement
|
||||||
|
this.requiresTwoFactorSubject.next(false);
|
||||||
|
this.deviceVerificationSubject.next(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Legacy 2FA verification
|
||||||
|
const verification = codeOrVerification as TwoFactorVerification;
|
||||||
|
return this.http.post<AuthResponse>(ApiEndpoints.AUTH.TWO_FACTOR.VERIFY, verification)
|
||||||
|
.pipe(
|
||||||
|
timeout(this.REQUEST_TIMEOUT),
|
||||||
|
tap(response => this.handleSuccessfulAuth(response, false)),
|
||||||
|
catchError(error => this.handleAuthError(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear device verification state
|
||||||
|
*/
|
||||||
|
clearDeviceVerification(): void {
|
||||||
|
this.deviceVerificationSubject.next(null);
|
||||||
|
this.requiresTwoFactorSubject.next(false);
|
||||||
|
this.deviceFingerprintService.clearTwoFactorSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop loading states
|
||||||
|
*/
|
||||||
|
private stopLoading(): void {
|
||||||
|
this.loadingService.stopGlobalLoading();
|
||||||
|
this.loadingService.stopFormLoading('login-form');
|
||||||
|
this.isLoadingSubject.next(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User registration with validation and secure password transmission
|
||||||
|
*/
|
||||||
|
register(registerData: RegisterData): Observable<AuthResponse> {
|
||||||
|
// Start both global and form loading
|
||||||
|
this.loadingService.startGlobalLoading();
|
||||||
|
this.loadingService.startFormLoading('register-form');
|
||||||
|
this.isLoadingSubject.next(true);
|
||||||
|
|
||||||
|
// Create secure encrypted payloads for both password fields
|
||||||
|
const passwordPayload = this.encryptionService.createSecurePayload(registerData.password);
|
||||||
|
const confirmPasswordPayload = this.encryptionService.createSecurePayload(registerData.confirmPassword);
|
||||||
|
|
||||||
|
// Transform RegisterData to match backend with encrypted passwords
|
||||||
|
const registerRequest = {
|
||||||
|
firstName: registerData.firstName,
|
||||||
|
lastName: registerData.lastName,
|
||||||
|
email: registerData.email,
|
||||||
|
encryptedPassword: passwordPayload.encryptedPassword,
|
||||||
|
passwordEncryptionMeta: passwordPayload.encryptionMeta,
|
||||||
|
encryptedConfirmPassword: confirmPasswordPayload.encryptedPassword,
|
||||||
|
confirmPasswordEncryptionMeta: confirmPasswordPayload.encryptionMeta
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.post<AuthResponse>(ApiEndpoints.AUTH.REGISTER, registerRequest)
|
||||||
|
.pipe(
|
||||||
|
timeout(this.REQUEST_TIMEOUT),
|
||||||
|
retry(this.MAX_RETRY_ATTEMPTS),
|
||||||
|
tap(response => this.handleSuccessfulAuth(response, false)),
|
||||||
|
catchError(error => this.handleAuthError(error)),
|
||||||
|
tap(() => {
|
||||||
|
// Stop all loading states
|
||||||
|
this.loadingService.stopGlobalLoading();
|
||||||
|
this.loadingService.stopFormLoading('register-form');
|
||||||
|
this.isLoadingSubject.next(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure logout with token invalidation
|
||||||
|
*/
|
||||||
|
logout(): Observable<any> {
|
||||||
|
const refreshToken = this.getRefreshToken();
|
||||||
|
|
||||||
|
// Clear local state immediately
|
||||||
|
this.clearAuthState();
|
||||||
|
this.clearTokenRefreshTimer();
|
||||||
|
|
||||||
|
// Notify server to invalidate tokens
|
||||||
|
if (refreshToken) {
|
||||||
|
return this.http.post(ApiEndpoints.AUTH.LOGOUT, { refreshToken })
|
||||||
|
.pipe(
|
||||||
|
catchError(() => of(null)), // Continue even if server request fails
|
||||||
|
tap(() => this.router.navigate(['/login']))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh authentication token
|
||||||
|
*/
|
||||||
|
refreshToken(): Observable<AuthTokens> {
|
||||||
|
const refreshToken = this.getRefreshToken();
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return throwError(() => new Error('No refresh token available'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<AuthTokens>(ApiEndpoints.AUTH.REFRESH_TOKEN, { refreshToken })
|
||||||
|
.pipe(
|
||||||
|
tap(tokens => this.storeTokens(tokens)),
|
||||||
|
catchError(error => {
|
||||||
|
this.clearAuthState();
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request password reset
|
||||||
|
*/
|
||||||
|
requestPasswordReset(request: PasswordResetRequest): Observable<{ message: string }> {
|
||||||
|
return this.http.post<{ message: string }>(ApiEndpoints.AUTH.PASSWORD_RESET.REQUEST, request)
|
||||||
|
.pipe(
|
||||||
|
timeout(this.REQUEST_TIMEOUT),
|
||||||
|
catchError(error => this.handleAuthError(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm password reset
|
||||||
|
*/
|
||||||
|
confirmPasswordReset(confirmation: PasswordResetConfirmation): Observable<{ message: string }> {
|
||||||
|
return this.http.post<{ message: string }>(ApiEndpoints.AUTH.PASSWORD_RESET.CONFIRM, confirmation)
|
||||||
|
.pipe(
|
||||||
|
timeout(this.REQUEST_TIMEOUT),
|
||||||
|
catchError(error => this.handleAuthError(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup two-factor authentication
|
||||||
|
*/
|
||||||
|
setupTwoFactor(): Observable<TwoFactorSetup> {
|
||||||
|
return this.http.post<TwoFactorSetup>(ApiEndpoints.AUTH.TWO_FACTOR.SETUP, {})
|
||||||
|
.pipe(
|
||||||
|
timeout(this.REQUEST_TIMEOUT),
|
||||||
|
catchError(error => this.handleAuthError(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user profile
|
||||||
|
*/
|
||||||
|
getCurrentUser(): Observable<AuthUser> {
|
||||||
|
return this.http.get<AuthUser>(ApiEndpoints.AUTH.PROFILE)
|
||||||
|
.pipe(
|
||||||
|
tap(user => {
|
||||||
|
this.currentUserSubject.next(user);
|
||||||
|
this.storeUserData(user);
|
||||||
|
}),
|
||||||
|
catchError(error => this.handleAuthError(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user profile
|
||||||
|
*/
|
||||||
|
updateProfile(userData: Partial<AuthUser>): Observable<AuthUser> {
|
||||||
|
return this.http.put<AuthUser>(ApiEndpoints.AUTH.PROFILE, userData)
|
||||||
|
.pipe(
|
||||||
|
tap(user => {
|
||||||
|
this.currentUserSubject.next(user);
|
||||||
|
this.storeUserData(user);
|
||||||
|
}),
|
||||||
|
catchError(error => this.handleAuthError(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has specific role
|
||||||
|
*/
|
||||||
|
hasRole(role: string): boolean {
|
||||||
|
const user = this.currentUserSubject.value;
|
||||||
|
return user ? (user.roles || []).includes(role) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has specific permission
|
||||||
|
*/
|
||||||
|
hasPermission(permission: string): boolean {
|
||||||
|
const user = this.currentUserSubject.value;
|
||||||
|
return user ? (user.permissions || []).includes(permission) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current authentication status
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return this.isAuthenticatedSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user
|
||||||
|
*/
|
||||||
|
getCurrentUserValue(): AuthUser | null {
|
||||||
|
return this.currentUserSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful authentication
|
||||||
|
*/
|
||||||
|
private handleSuccessfulAuth(response: AuthResponse, rememberMe: boolean = false): void {
|
||||||
|
// Only handle successful auth if we have an access token
|
||||||
|
if (!response.accessToken) {
|
||||||
|
throw new Error('Invalid authentication response: missing access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tokens object from NestJS response
|
||||||
|
const tokens: AuthTokens = {
|
||||||
|
accessToken: response.accessToken
|
||||||
|
// refreshToken, expiresIn, tokenType are optional and not provided by NestJS initially
|
||||||
|
};
|
||||||
|
|
||||||
|
this.storeTokens(tokens);
|
||||||
|
this.storeUserData(response.user);
|
||||||
|
|
||||||
|
if (rememberMe) {
|
||||||
|
localStorage.setItem(this.REMEMBER_ME_KEY, 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentUserSubject.next(response.user);
|
||||||
|
this.isAuthenticatedSubject.next(true);
|
||||||
|
// Note: Token refresh scheduling may need to be adjusted for single token approach
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle authentication errors
|
||||||
|
*/
|
||||||
|
private handleAuthError(error: HttpErrorResponse): Observable<never> {
|
||||||
|
let authError: AuthError;
|
||||||
|
|
||||||
|
if (error.error instanceof ErrorEvent) {
|
||||||
|
// Client-side error
|
||||||
|
authError = {
|
||||||
|
code: 'CLIENT_ERROR',
|
||||||
|
message: 'Network error occurred. Please check your connection.',
|
||||||
|
details: error.error.message,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Server-side error
|
||||||
|
authError = {
|
||||||
|
code: error.error?.code || `HTTP_${error.status}`,
|
||||||
|
message: error.error?.message || 'An authentication error occurred.',
|
||||||
|
details: error.error?.details,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Authentication Error:', authError);
|
||||||
|
return throwError(() => authError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store authentication tokens securely
|
||||||
|
* Compatible with both simple and full token responses
|
||||||
|
*/
|
||||||
|
private storeTokens(tokens: AuthTokens): void {
|
||||||
|
if (localStorage.getItem(this.REMEMBER_ME_KEY)) {
|
||||||
|
localStorage.setItem(this.ACCESS_TOKEN_KEY, tokens.accessToken);
|
||||||
|
if (tokens.refreshToken) {
|
||||||
|
localStorage.setItem(this.REFRESH_TOKEN_KEY, tokens.refreshToken);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokens.accessToken);
|
||||||
|
if (tokens.refreshToken) {
|
||||||
|
sessionStorage.setItem(this.REFRESH_TOKEN_KEY, tokens.refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store user data
|
||||||
|
*/
|
||||||
|
private storeUserData(user: AuthUser): void {
|
||||||
|
const storage = localStorage.getItem(this.REMEMBER_ME_KEY) ? localStorage : sessionStorage;
|
||||||
|
storage.setItem(this.USER_DATA_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored access token
|
||||||
|
*/
|
||||||
|
private getAccessToken(): string | null {
|
||||||
|
return localStorage.getItem(this.ACCESS_TOKEN_KEY) ||
|
||||||
|
sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored refresh token
|
||||||
|
*/
|
||||||
|
private getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(this.REFRESH_TOKEN_KEY) ||
|
||||||
|
sessionStorage.getItem(this.REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored user data
|
||||||
|
*/
|
||||||
|
private getUserData(): AuthUser | null {
|
||||||
|
const userData = localStorage.getItem(this.USER_DATA_KEY) ||
|
||||||
|
sessionStorage.getItem(this.USER_DATA_KEY);
|
||||||
|
return userData ? JSON.parse(userData) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear authentication state
|
||||||
|
*/
|
||||||
|
private clearAuthState(): void {
|
||||||
|
// Clear from both storages
|
||||||
|
[localStorage, sessionStorage].forEach(storage => {
|
||||||
|
storage.removeItem(this.ACCESS_TOKEN_KEY);
|
||||||
|
storage.removeItem(this.REFRESH_TOKEN_KEY);
|
||||||
|
storage.removeItem(this.USER_DATA_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.removeItem(this.REMEMBER_ME_KEY);
|
||||||
|
|
||||||
|
this.currentUserSubject.next(null);
|
||||||
|
this.isAuthenticatedSubject.next(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token is valid and not expired
|
||||||
|
*/
|
||||||
|
private isTokenValid(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const payload = this.decodeJwtPayload(token);
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
return payload.exp > currentTime;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode JWT token payload
|
||||||
|
*/
|
||||||
|
private decodeJwtPayload(token: string): JwtPayload {
|
||||||
|
const base64Url = token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
);
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup automatic token refresh
|
||||||
|
*/
|
||||||
|
private setupTokenRefreshTimer(): void {
|
||||||
|
// Check token validity every minute
|
||||||
|
timer(0, 60000).subscribe(() => {
|
||||||
|
if (this.isAuthenticated()) {
|
||||||
|
const token = this.getAccessToken();
|
||||||
|
if (token && this.shouldRefreshToken(token)) {
|
||||||
|
this.refreshToken().subscribe({
|
||||||
|
error: () => this.logout()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if token should be refreshed
|
||||||
|
*/
|
||||||
|
private shouldRefreshToken(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const payload = this.decodeJwtPayload(token);
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
const timeUntilExpiry = (payload.exp - currentTime) * 1000;
|
||||||
|
return timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule token refresh
|
||||||
|
*/
|
||||||
|
private scheduleTokenRefresh(): void {
|
||||||
|
this.clearTokenRefreshTimer();
|
||||||
|
|
||||||
|
const token = this.getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = this.decodeJwtPayload(token);
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
const refreshTime = (payload.exp - currentTime - 300) * 1000; // 5 minutes before expiry
|
||||||
|
|
||||||
|
if (refreshTime > 0) {
|
||||||
|
this.tokenRefreshTimer = setTimeout(() => {
|
||||||
|
this.refreshToken().subscribe({
|
||||||
|
error: () => this.logout()
|
||||||
|
});
|
||||||
|
}, refreshTime);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Token is invalid, logout
|
||||||
|
this.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear token refresh timer
|
||||||
|
*/
|
||||||
|
private clearTokenRefreshTimer(): void {
|
||||||
|
if (this.tokenRefreshTimer) {
|
||||||
|
clearTimeout(this.tokenRefreshTimer);
|
||||||
|
this.tokenRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/app/services/device-fingerprint.service.ts
Normal file
249
src/app/services/device-fingerprint.service.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Device Fingerprinting Service
|
||||||
|
* Professional Angular Resume Builder - Browser Fingerprinting System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, BehaviorSubject, throwError } from 'rxjs';
|
||||||
|
import { map, catchError, tap } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import {
|
||||||
|
DeviceFingerprint,
|
||||||
|
DeviceVerificationRequest,
|
||||||
|
DeviceVerificationResponse,
|
||||||
|
TwoFactorInitiationRequest,
|
||||||
|
TwoFactorInitiationResponse,
|
||||||
|
TwoFactorVerificationRequest
|
||||||
|
} from '../models/auth.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for generating device fingerprints and managing device trust
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class DeviceFingerprintService {
|
||||||
|
private readonly apiUrl = `${environment.apiUrl}/device-fingerprint`;
|
||||||
|
|
||||||
|
// State management for 2FA process
|
||||||
|
private currentTwoFactorSession = new BehaviorSubject<TwoFactorInitiationResponse | null>(null);
|
||||||
|
public readonly currentTwoFactorSession$ = this.currentTwoFactorSession.asObservable();
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate browser fingerprint from available browser APIs
|
||||||
|
*/
|
||||||
|
generateFingerprint(): DeviceFingerprint {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let canvasFingerprint = '';
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.font = '14px Arial';
|
||||||
|
ctx.fillText('Device fingerprint test 🔒', 2, 2);
|
||||||
|
canvasFingerprint = canvas.toDataURL().slice(-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect available fonts (simplified approach)
|
||||||
|
const testFonts = ['Arial', 'Helvetica', 'Times', 'Georgia', 'Verdana', 'Courier'];
|
||||||
|
const availableFonts = testFonts.filter(font => this.isFontAvailable(font));
|
||||||
|
const fontsHash = this.hashString(availableFonts.join(','));
|
||||||
|
|
||||||
|
const fingerprint: DeviceFingerprint = {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
acceptLanguage: navigator.language || (navigator as any).userLanguage || '',
|
||||||
|
screenResolution: `${screen.width}x${screen.height}`,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
platform: navigator.platform,
|
||||||
|
cookieEnabled: navigator.cookieEnabled,
|
||||||
|
doNotTrack: navigator.doNotTrack === '1',
|
||||||
|
fontsHash: fontsHash,
|
||||||
|
canvasFingerprint: canvasFingerprint,
|
||||||
|
webglFingerprint: this.getWebGLFingerprint()
|
||||||
|
};
|
||||||
|
|
||||||
|
return fingerprint;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating fingerprint:', error);
|
||||||
|
// Return minimal fingerprint on error
|
||||||
|
return {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
acceptLanguage: navigator.language || '',
|
||||||
|
screenResolution: `${screen.width}x${screen.height}`,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
platform: navigator.platform,
|
||||||
|
cookieEnabled: navigator.cookieEnabled,
|
||||||
|
doNotTrack: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify device trust status with backend
|
||||||
|
*/
|
||||||
|
verifyDevice(userId: number): Observable<DeviceVerificationResponse> {
|
||||||
|
const fingerprint = this.generateFingerprint();
|
||||||
|
const request: DeviceVerificationRequest = {
|
||||||
|
userId,
|
||||||
|
fingerprint
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.post<DeviceVerificationResponse>(`${this.apiUrl}/verify`, request)
|
||||||
|
.pipe(
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Device verification failed:', error);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate two-factor authentication for device registration
|
||||||
|
*/
|
||||||
|
initiateTwoFactor(userId: number, deviceName?: string): Observable<TwoFactorInitiationResponse> {
|
||||||
|
const request: TwoFactorInitiationRequest = {
|
||||||
|
userId,
|
||||||
|
method: 'email' // Default method for demo mode
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.post<TwoFactorInitiationResponse>(`${this.apiUrl}/two-factor/initiate`, request)
|
||||||
|
.pipe(
|
||||||
|
tap(response => {
|
||||||
|
// Store the 2FA session
|
||||||
|
this.currentTwoFactorSession.next(response);
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('2FA initiation failed:', error);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify two-factor authentication code
|
||||||
|
*/
|
||||||
|
verifyTwoFactor(code: string, verificationId: string, userId: number, tempToken?: string): Observable<any> {
|
||||||
|
const request: TwoFactorVerificationRequest = {
|
||||||
|
code,
|
||||||
|
verificationId,
|
||||||
|
userId,
|
||||||
|
tempToken // Include temp token if provided
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.post<any>(`${this.apiUrl}/two-factor/verify`, request)
|
||||||
|
.pipe(
|
||||||
|
tap(response => {
|
||||||
|
// Clear the 2FA session on success
|
||||||
|
if (response.success) {
|
||||||
|
this.currentTwoFactorSession.next(null);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('2FA verification failed:', error);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear current 2FA session
|
||||||
|
*/
|
||||||
|
clearTwoFactorSession(): void {
|
||||||
|
this.currentTwoFactorSession.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a font is available by measuring text width
|
||||||
|
*/
|
||||||
|
private isFontAvailable(fontName: string): boolean {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return false;
|
||||||
|
|
||||||
|
const baselineText = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
ctx.font = '32px monospace';
|
||||||
|
const baselineWidth = ctx.measureText(baselineText).width;
|
||||||
|
|
||||||
|
ctx.font = `32px ${fontName}, monospace`;
|
||||||
|
const testWidth = ctx.measureText(baselineText).width;
|
||||||
|
|
||||||
|
return baselineWidth !== testWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WebGL fingerprint for additional uniqueness
|
||||||
|
*/
|
||||||
|
private getWebGLFingerprint(): string {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') as WebGLRenderingContext;
|
||||||
|
if (!gl) return '';
|
||||||
|
|
||||||
|
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
if (debugInfo) {
|
||||||
|
const vendor = gl.getParameter((debugInfo as any).UNMASKED_VENDOR_WEBGL);
|
||||||
|
const renderer = gl.getParameter((debugInfo as any).UNMASKED_RENDERER_WEBGL);
|
||||||
|
return this.hashString(`${vendor}|${renderer}`).slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
} catch (error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate device name based on browser and platform info
|
||||||
|
*/
|
||||||
|
private getDeviceName(): string {
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
let deviceName = 'Unknown Device';
|
||||||
|
|
||||||
|
if (userAgent.includes('Windows')) {
|
||||||
|
deviceName = 'Windows Device';
|
||||||
|
} else if (userAgent.includes('Mac')) {
|
||||||
|
deviceName = 'Mac Device';
|
||||||
|
} else if (userAgent.includes('Linux')) {
|
||||||
|
deviceName = 'Linux Device';
|
||||||
|
} else if (userAgent.includes('Android')) {
|
||||||
|
deviceName = 'Android Device';
|
||||||
|
} else if (userAgent.includes('iOS') || userAgent.includes('iPhone') || userAgent.includes('iPad')) {
|
||||||
|
deviceName = 'iOS Device';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add browser info
|
||||||
|
if (userAgent.includes('Chrome')) {
|
||||||
|
deviceName += ' (Chrome)';
|
||||||
|
} else if (userAgent.includes('Firefox')) {
|
||||||
|
deviceName += ' (Firefox)';
|
||||||
|
} else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
|
||||||
|
deviceName += ' (Safari)';
|
||||||
|
} else if (userAgent.includes('Edge')) {
|
||||||
|
deviceName += ' (Edge)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple string hashing function
|
||||||
|
*/
|
||||||
|
private hashString(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32-bit integer
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/app/services/encryption.service.ts
Normal file
137
src/app/services/encryption.service.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Professional Encryption Service
|
||||||
|
* Author: David Valera Melendez
|
||||||
|
* Email: david@valera-melendez.de
|
||||||
|
*
|
||||||
|
* Handles secure password encryption for frontend-backend communication
|
||||||
|
* Uses AES-256-GCM encryption for maximum security
|
||||||
|
*/
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import * as CryptoJS from 'crypto-js';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encryption configuration interface
|
||||||
|
*/
|
||||||
|
interface EncryptionResult {
|
||||||
|
encryptedData: string;
|
||||||
|
iv: string;
|
||||||
|
salt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class EncryptionService {
|
||||||
|
|
||||||
|
// Professional encryption configuration from environment
|
||||||
|
private readonly ENCRYPTION_KEY: string;
|
||||||
|
private readonly KEY_SIZE: number;
|
||||||
|
private readonly IV_SIZE = 16;
|
||||||
|
private readonly SALT_SIZE = 16;
|
||||||
|
private readonly ITERATIONS: number;
|
||||||
|
private readonly ALGORITHM: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Get encryption configuration from environment
|
||||||
|
this.ENCRYPTION_KEY = environment.security.encryptionKey;
|
||||||
|
this.KEY_SIZE = environment.security.keySize;
|
||||||
|
this.ITERATIONS = environment.security.iterations;
|
||||||
|
this.ALGORITHM = environment.security.encryptionAlgorithm;
|
||||||
|
|
||||||
|
// Validate environment configuration
|
||||||
|
if (!this.ENCRYPTION_KEY) {
|
||||||
|
throw new Error('Encryption key not found in environment configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ENCRYPTION_KEY.length < 32) {
|
||||||
|
throw new Error('Encryption key must be at least 32 characters long');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts password using AES-256-GCM with random IV and salt
|
||||||
|
* @param password The plain text password to encrypt
|
||||||
|
* @returns Encryption result with encrypted data, IV, and salt
|
||||||
|
*/
|
||||||
|
encryptPassword(password: string): EncryptionResult {
|
||||||
|
try {
|
||||||
|
// Generate random salt and IV for each encryption
|
||||||
|
const salt = CryptoJS.lib.WordArray.random(this.SALT_SIZE);
|
||||||
|
const iv = CryptoJS.lib.WordArray.random(this.IV_SIZE);
|
||||||
|
|
||||||
|
// Derive key using PBKDF2
|
||||||
|
const key = CryptoJS.PBKDF2(this.ENCRYPTION_KEY, salt, {
|
||||||
|
keySize: this.KEY_SIZE / 32,
|
||||||
|
iterations: this.ITERATIONS
|
||||||
|
});
|
||||||
|
|
||||||
|
// Encrypt the password
|
||||||
|
const encrypted = CryptoJS.AES.encrypt(password, key, {
|
||||||
|
iv: iv,
|
||||||
|
mode: CryptoJS.mode.CBC,
|
||||||
|
padding: CryptoJS.pad.Pkcs7
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedData: encrypted.toString(),
|
||||||
|
iv: CryptoJS.enc.Base64.stringify(iv),
|
||||||
|
salt: CryptoJS.enc.Base64.stringify(salt)
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Encryption failed:', error);
|
||||||
|
throw new Error('Password encryption failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a secure payload for backend transmission
|
||||||
|
* @param password The plain text password
|
||||||
|
* @returns Secure payload object
|
||||||
|
*/
|
||||||
|
createSecurePayload(password: string): {
|
||||||
|
encryptedPassword: string;
|
||||||
|
encryptionMeta: {
|
||||||
|
iv: string;
|
||||||
|
salt: string;
|
||||||
|
algorithm: string;
|
||||||
|
keySize: number;
|
||||||
|
iterations: number;
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
const encryptionResult = this.encryptPassword(password);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedPassword: encryptionResult.encryptedData,
|
||||||
|
encryptionMeta: {
|
||||||
|
iv: encryptionResult.iv,
|
||||||
|
salt: encryptionResult.salt,
|
||||||
|
algorithm: this.ALGORITHM,
|
||||||
|
keySize: this.KEY_SIZE,
|
||||||
|
iterations: this.ITERATIONS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a secure hash for password verification (client-side only)
|
||||||
|
* This is NOT sent to backend, only for local validation
|
||||||
|
*/
|
||||||
|
generatePasswordHash(password: string): string {
|
||||||
|
return CryptoJS.SHA256(password + this.ENCRYPTION_KEY).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates encryption parameters
|
||||||
|
* @param encryptionMeta Encryption metadata to validate
|
||||||
|
* @returns boolean indicating if parameters are valid
|
||||||
|
*/
|
||||||
|
validateEncryptionMeta(encryptionMeta: any): boolean {
|
||||||
|
return encryptionMeta &&
|
||||||
|
encryptionMeta.iv &&
|
||||||
|
encryptionMeta.salt &&
|
||||||
|
encryptionMeta.algorithm === this.ALGORITHM &&
|
||||||
|
encryptionMeta.keySize === this.KEY_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/app/services/http.service.ts
Normal file
251
src/app/services/http.service.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* HTTP Service
|
||||||
|
* Professional Angular Resume Builder - HTTP Client Wrapper
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpHeaders, HttpParams, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Observable, throwError } from 'rxjs';
|
||||||
|
import { catchError, timeout, retry } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { ApiEndpoints, HttpMethod, ApiRequestConfig } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Professional HTTP Service with consistent error handling and configuration
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class HttpService {
|
||||||
|
private readonly DEFAULT_TIMEOUT = 30000; // 30 seconds
|
||||||
|
private readonly DEFAULT_RETRY_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic request method with professional configuration
|
||||||
|
*/
|
||||||
|
request<T>(config: ApiRequestConfig): Observable<T> {
|
||||||
|
const {
|
||||||
|
endpoint,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
params,
|
||||||
|
headers,
|
||||||
|
timeout: requestTimeout = this.DEFAULT_TIMEOUT,
|
||||||
|
retries = this.DEFAULT_RETRY_ATTEMPTS
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// Build HTTP options
|
||||||
|
const httpOptions = this.buildHttpOptions(headers, params);
|
||||||
|
|
||||||
|
// Validate endpoint
|
||||||
|
if (!ApiEndpoints.isValidEndpoint(endpoint)) {
|
||||||
|
return throwError(() => new Error('Invalid endpoint URL'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
let request$: Observable<T>;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case HttpMethod.GET:
|
||||||
|
request$ = this.http.get<T>(endpoint, httpOptions);
|
||||||
|
break;
|
||||||
|
case HttpMethod.POST:
|
||||||
|
request$ = this.http.post<T>(endpoint, body, httpOptions);
|
||||||
|
break;
|
||||||
|
case HttpMethod.PUT:
|
||||||
|
request$ = this.http.put<T>(endpoint, body, httpOptions);
|
||||||
|
break;
|
||||||
|
case HttpMethod.PATCH:
|
||||||
|
request$ = this.http.patch<T>(endpoint, body, httpOptions);
|
||||||
|
break;
|
||||||
|
case HttpMethod.DELETE:
|
||||||
|
request$ = this.http.delete<T>(endpoint, httpOptions);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return throwError(() => new Error(`Unsupported HTTP method: ${method}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return request$.pipe(
|
||||||
|
timeout(requestTimeout),
|
||||||
|
retry(retries),
|
||||||
|
catchError(error => this.handleError(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for GET requests
|
||||||
|
*/
|
||||||
|
get<T>(endpoint: string, params?: Record<string, any>): Observable<T> {
|
||||||
|
const url = params ? ApiEndpoints.withQuery(endpoint, params) : endpoint;
|
||||||
|
return this.request<T>({
|
||||||
|
endpoint: url,
|
||||||
|
method: HttpMethod.GET
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for POST requests
|
||||||
|
*/
|
||||||
|
post<T>(endpoint: string, body: any, headers?: Record<string, string>): Observable<T> {
|
||||||
|
return this.request<T>({
|
||||||
|
endpoint,
|
||||||
|
method: HttpMethod.POST,
|
||||||
|
body,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for PUT requests
|
||||||
|
*/
|
||||||
|
put<T>(endpoint: string, body: any, headers?: Record<string, string>): Observable<T> {
|
||||||
|
return this.request<T>({
|
||||||
|
endpoint,
|
||||||
|
method: HttpMethod.PUT,
|
||||||
|
body,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for PATCH requests
|
||||||
|
*/
|
||||||
|
patch<T>(endpoint: string, body: any, headers?: Record<string, string>): Observable<T> {
|
||||||
|
return this.request<T>({
|
||||||
|
endpoint,
|
||||||
|
method: HttpMethod.PATCH,
|
||||||
|
body,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for DELETE requests
|
||||||
|
*/
|
||||||
|
delete<T>(endpoint: string, headers?: Record<string, string>): Observable<T> {
|
||||||
|
return this.request<T>({
|
||||||
|
endpoint,
|
||||||
|
method: HttpMethod.DELETE,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file with progress tracking
|
||||||
|
*/
|
||||||
|
uploadFile<T>(endpoint: string, file: File, additionalData?: Record<string, any>): Observable<T> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file, file.name);
|
||||||
|
|
||||||
|
// Add additional form data if provided
|
||||||
|
if (additionalData) {
|
||||||
|
Object.entries(additionalData).forEach(([key, value]) => {
|
||||||
|
formData.append(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.post<T>(endpoint, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file from endpoint
|
||||||
|
*/
|
||||||
|
downloadFile(endpoint: string, filename?: string): Observable<Blob> {
|
||||||
|
return this.http.get(endpoint, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTTP options from headers and params
|
||||||
|
*/
|
||||||
|
private buildHttpOptions(headers?: Record<string, string>, params?: Record<string, any>) {
|
||||||
|
let httpHeaders = new HttpHeaders({
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom headers
|
||||||
|
if (headers) {
|
||||||
|
Object.entries(headers).forEach(([key, value]) => {
|
||||||
|
httpHeaders = httpHeaders.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTTP params
|
||||||
|
let httpParams = new HttpParams();
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined && value !== '') {
|
||||||
|
httpParams = httpParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: httpHeaders,
|
||||||
|
params: httpParams
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Professional error handling
|
||||||
|
*/
|
||||||
|
private handleError(error: HttpErrorResponse): Observable<never> {
|
||||||
|
let errorMessage = 'An unknown error occurred';
|
||||||
|
|
||||||
|
if (error.error instanceof ErrorEvent) {
|
||||||
|
// Client-side error
|
||||||
|
errorMessage = `Client Error: ${error.error.message}`;
|
||||||
|
} else {
|
||||||
|
// Server-side error
|
||||||
|
switch (error.status) {
|
||||||
|
case 400:
|
||||||
|
errorMessage = 'Bad Request: ' + (error.error?.message || 'Invalid request');
|
||||||
|
break;
|
||||||
|
case 401:
|
||||||
|
errorMessage = 'Unauthorized: Please check your credentials';
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
errorMessage = 'Forbidden: You do not have permission to access this resource';
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
errorMessage = 'Not Found: The requested resource was not found';
|
||||||
|
break;
|
||||||
|
case 409:
|
||||||
|
errorMessage = 'Conflict: ' + (error.error?.message || 'Resource conflict');
|
||||||
|
break;
|
||||||
|
case 422:
|
||||||
|
errorMessage = 'Validation Error: ' + (error.error?.message || 'Invalid data provided');
|
||||||
|
break;
|
||||||
|
case 429:
|
||||||
|
errorMessage = 'Too Many Requests: Please wait before making another request';
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
errorMessage = 'Internal Server Error: Please try again later';
|
||||||
|
break;
|
||||||
|
case 503:
|
||||||
|
errorMessage = 'Service Unavailable: The server is currently unavailable';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = `HTTP Error ${error.status}: ${error.error?.message || error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('HTTP Service Error:', {
|
||||||
|
status: error.status,
|
||||||
|
message: errorMessage,
|
||||||
|
url: error.url,
|
||||||
|
error: error.error
|
||||||
|
});
|
||||||
|
|
||||||
|
return throwError(() => ({
|
||||||
|
status: error.status,
|
||||||
|
message: errorMessage,
|
||||||
|
originalError: error
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/services/loading.service.ts
Normal file
134
src/app/services/loading.service.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Global Loading Service
|
||||||
|
* Professional Angular Resume Builder - Enterprise Loading System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class LoadingService {
|
||||||
|
private formLoadingSubject = new BehaviorSubject<{ [key: string]: boolean }>({});
|
||||||
|
private globalLoadingSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
private progressSubject = new BehaviorSubject<number>(0);
|
||||||
|
|
||||||
|
// Active loading operations counter
|
||||||
|
private activeOperations = 0;
|
||||||
|
|
||||||
|
// Form-specific loading states
|
||||||
|
public formLoading$ = this.formLoadingSubject.asObservable();
|
||||||
|
public globalLoading$ = this.globalLoadingSubject.asObservable();
|
||||||
|
public progress$ = this.progressSubject.asObservable();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start global loading with progress bar
|
||||||
|
*/
|
||||||
|
startGlobalLoading(): void {
|
||||||
|
this.activeOperations++;
|
||||||
|
if (this.activeOperations === 1) {
|
||||||
|
this.globalLoadingSubject.next(true);
|
||||||
|
this.animateProgressBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop global loading and hide progress bar
|
||||||
|
*/
|
||||||
|
stopGlobalLoading(): void {
|
||||||
|
this.activeOperations = Math.max(0, this.activeOperations - 1);
|
||||||
|
if (this.activeOperations === 0) {
|
||||||
|
this.completeProgressBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate progress bar from 0 to 90% then wait
|
||||||
|
*/
|
||||||
|
private animateProgressBar(): void {
|
||||||
|
this.progressSubject.next(0);
|
||||||
|
let progress = 0;
|
||||||
|
const increment = 2;
|
||||||
|
|
||||||
|
const animation = setInterval(() => {
|
||||||
|
progress += increment;
|
||||||
|
if (progress >= 90) {
|
||||||
|
progress = 90;
|
||||||
|
clearInterval(animation);
|
||||||
|
}
|
||||||
|
this.progressSubject.next(progress);
|
||||||
|
}, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete progress bar animation
|
||||||
|
*/
|
||||||
|
private completeProgressBar(): void {
|
||||||
|
this.progressSubject.next(100);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.globalLoadingSubject.next(false);
|
||||||
|
this.progressSubject.next(0);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set progress bar value manually (0-100)
|
||||||
|
*/
|
||||||
|
setProgress(value: number): void {
|
||||||
|
this.progressSubject.next(Math.min(100, Math.max(0, value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start form-specific loading
|
||||||
|
*/
|
||||||
|
startFormLoading(formId: string): void {
|
||||||
|
const currentState = this.formLoadingSubject.value;
|
||||||
|
this.formLoadingSubject.next({
|
||||||
|
...currentState,
|
||||||
|
[formId]: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop form-specific loading
|
||||||
|
*/
|
||||||
|
stopFormLoading(formId: string): void {
|
||||||
|
const currentState = this.formLoadingSubject.value;
|
||||||
|
const newState = { ...currentState };
|
||||||
|
delete newState[formId];
|
||||||
|
this.formLoadingSubject.next(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if specific form is loading
|
||||||
|
*/
|
||||||
|
isFormLoading(formId: string): Observable<boolean> {
|
||||||
|
return new Observable(observer => {
|
||||||
|
this.formLoading$.subscribe(loadingState => {
|
||||||
|
observer.next(!!loadingState[formId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current global loading state
|
||||||
|
*/
|
||||||
|
isGlobalLoading(): boolean {
|
||||||
|
return this.globalLoadingSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all loading states
|
||||||
|
*/
|
||||||
|
resetAllLoading(): void {
|
||||||
|
this.activeOperations = 0;
|
||||||
|
this.globalLoadingSubject.next(false);
|
||||||
|
this.formLoadingSubject.next({});
|
||||||
|
this.progressSubject.next(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/app/services/resume.service.ts
Normal file
365
src/app/services/resume.service.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
/**
|
||||||
|
* Resume Service
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
import { ResumeData, PersonalInfo, Experience, Education, Skill } from '../models/resume.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume Service - Central data management for resume builder
|
||||||
|
* Handles data persistence, state management, and data operations
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ResumeService {
|
||||||
|
private readonly STORAGE_KEY = 'david_valera_resume_data';
|
||||||
|
|
||||||
|
// Reactive state management
|
||||||
|
private resumeDataSubject = new BehaviorSubject<ResumeData>(this.getEmptyResumeData());
|
||||||
|
public resumeData$ = this.resumeDataSubject.asObservable();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Load existing data on service initialization
|
||||||
|
this.loadFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current resume data
|
||||||
|
* @returns Current resume data
|
||||||
|
*/
|
||||||
|
getResumeData(): ResumeData {
|
||||||
|
return this.resumeDataSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update resume data and persist to storage
|
||||||
|
* @param resumeData Complete resume data object
|
||||||
|
*/
|
||||||
|
saveResumeData(resumeData: ResumeData): void {
|
||||||
|
resumeData.updatedAt = new Date();
|
||||||
|
this.resumeDataSubject.next(resumeData);
|
||||||
|
this.saveToStorage(resumeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update personal information
|
||||||
|
* @param personalInfo Personal information object
|
||||||
|
*/
|
||||||
|
updatePersonalInfo(personalInfo: PersonalInfo): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
currentData.personalInfo = { ...personalInfo };
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new work experience
|
||||||
|
* @param experience Experience object to add
|
||||||
|
*/
|
||||||
|
addExperience(experience: Experience): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
currentData.experience.push(experience);
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing work experience
|
||||||
|
* @param index Index of experience to update
|
||||||
|
* @param experience Updated experience object
|
||||||
|
*/
|
||||||
|
updateExperience(index: number, experience: Experience): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
if (index >= 0 && index < currentData.experience.length) {
|
||||||
|
currentData.experience[index] = { ...experience };
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove work experience
|
||||||
|
* @param index Index of experience to remove
|
||||||
|
*/
|
||||||
|
removeExperience(index: number): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
if (index >= 0 && index < currentData.experience.length) {
|
||||||
|
currentData.experience.splice(index, 1);
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new education entry
|
||||||
|
* @param education Education object to add
|
||||||
|
*/
|
||||||
|
addEducation(education: Education): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
currentData.education.push(education);
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing education entry
|
||||||
|
* @param index Index of education to update
|
||||||
|
* @param education Updated education object
|
||||||
|
*/
|
||||||
|
updateEducation(index: number, education: Education): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
if (index >= 0 && index < currentData.education.length) {
|
||||||
|
currentData.education[index] = { ...education };
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove education entry
|
||||||
|
* @param index Index of education to remove
|
||||||
|
*/
|
||||||
|
removeEducation(index: number): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
if (index >= 0 && index < currentData.education.length) {
|
||||||
|
currentData.education.splice(index, 1);
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new skill
|
||||||
|
* @param skill Skill object to add
|
||||||
|
*/
|
||||||
|
addSkill(skill: Skill): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
currentData.skills.push(skill);
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing skill
|
||||||
|
* @param index Index of skill to update
|
||||||
|
* @param skill Updated skill object
|
||||||
|
*/
|
||||||
|
updateSkill(index: number, skill: Skill): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
if (index >= 0 && index < currentData.skills.length) {
|
||||||
|
currentData.skills[index] = { ...skill };
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove skill
|
||||||
|
* @param index Index of skill to remove
|
||||||
|
*/
|
||||||
|
removeSkill(index: number): void {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
if (index >= 0 && index < currentData.skills.length) {
|
||||||
|
currentData.skills.splice(index, 1);
|
||||||
|
this.saveResumeData(currentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export resume data as JSON
|
||||||
|
* @returns Resume data as JSON string
|
||||||
|
*/
|
||||||
|
exportAsJson(): string {
|
||||||
|
const currentData = this.getResumeData();
|
||||||
|
return JSON.stringify(currentData, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import resume data from JSON
|
||||||
|
* @param jsonData JSON string of resume data
|
||||||
|
* @returns Success status
|
||||||
|
*/
|
||||||
|
importFromJson(jsonData: string): boolean {
|
||||||
|
try {
|
||||||
|
const resumeData: ResumeData = JSON.parse(jsonData);
|
||||||
|
this.validateResumeData(resumeData);
|
||||||
|
this.saveResumeData(resumeData);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing resume data:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all resume data
|
||||||
|
*/
|
||||||
|
clearResumeData(): void {
|
||||||
|
const emptyData = this.getEmptyResumeData();
|
||||||
|
this.saveResumeData(emptyData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sample resume data for demonstration
|
||||||
|
* @returns Sample resume data
|
||||||
|
*/
|
||||||
|
getSampleResumeData(): ResumeData {
|
||||||
|
return {
|
||||||
|
personalInfo: {
|
||||||
|
firstName: 'David',
|
||||||
|
lastName: 'Valera Melendez',
|
||||||
|
jobTitle: 'Senior Full-Stack Developer',
|
||||||
|
email: 'david@valera-melendez.de',
|
||||||
|
phone: '+49 123 456 7890',
|
||||||
|
city: 'Munich',
|
||||||
|
country: 'Germany',
|
||||||
|
location: 'Munich, Germany',
|
||||||
|
summary: 'Experienced Full-Stack Developer with expertise in Angular, React, Node.js, and cloud technologies. Passionate about creating scalable web applications and leading development teams.',
|
||||||
|
linkedin: 'https://linkedin.com/in/david-valera-melendez',
|
||||||
|
github: 'https://github.com/davidvalera',
|
||||||
|
website: 'https://valera-melendez.de'
|
||||||
|
},
|
||||||
|
experience: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
position: 'Senior Full-Stack Developer',
|
||||||
|
company: 'TechCorp GmbH',
|
||||||
|
location: 'Munich, Germany',
|
||||||
|
startDate: '2022-01',
|
||||||
|
endDate: '',
|
||||||
|
isCurrentPosition: true,
|
||||||
|
description: 'Leading development of enterprise web applications using modern technologies.',
|
||||||
|
achievements: [
|
||||||
|
'Improved application performance by 40%',
|
||||||
|
'Led team of 5 developers',
|
||||||
|
'Implemented CI/CD pipeline reducing deployment time by 60%'
|
||||||
|
],
|
||||||
|
technologies: ['Angular', 'Node.js', 'MongoDB', 'AWS']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
education: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
degree: 'Master of Computer Science',
|
||||||
|
institution: 'Technical University of Munich',
|
||||||
|
location: 'Munich, Germany',
|
||||||
|
startDate: '2018-09',
|
||||||
|
endDate: '2020-07',
|
||||||
|
grade: '1.2',
|
||||||
|
description: 'Specialized in Software Engineering and Web Technologies'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
skills: [
|
||||||
|
{ id: '1', name: 'Angular', category: 'technical', proficiency: 'expert', proficiencyLevel: 9 },
|
||||||
|
{ id: '2', name: 'TypeScript', category: 'technical', proficiency: 'expert', proficiencyLevel: 9 },
|
||||||
|
{ id: '3', name: 'Node.js', category: 'technical', proficiency: 'advanced', proficiencyLevel: 8 },
|
||||||
|
{ id: '4', name: 'Leadership', category: 'soft', proficiency: 'advanced', proficiencyLevel: 8 }
|
||||||
|
],
|
||||||
|
languages: [
|
||||||
|
{ id: '1', name: 'German', proficiency: 'native', level: 'C2' },
|
||||||
|
{ id: '2', name: 'English', proficiency: 'fluent', level: 'C1' },
|
||||||
|
{ id: '3', name: 'Spanish', proficiency: 'conversational', level: 'B2' }
|
||||||
|
],
|
||||||
|
certifications: [],
|
||||||
|
projects: [],
|
||||||
|
awards: [],
|
||||||
|
references: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load resume data from localStorage
|
||||||
|
*/
|
||||||
|
private loadFromStorage(): void {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const resumeData: ResumeData = JSON.parse(stored);
|
||||||
|
this.validateResumeData(resumeData);
|
||||||
|
this.resumeDataSubject.next(resumeData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading resume data from storage:', error);
|
||||||
|
// If data is corrupted, start with empty data
|
||||||
|
this.resumeDataSubject.next(this.getEmptyResumeData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save resume data to localStorage
|
||||||
|
* @param resumeData Resume data to save
|
||||||
|
*/
|
||||||
|
private saveToStorage(resumeData: ResumeData): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(resumeData));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving resume data to storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate resume data structure
|
||||||
|
* @param resumeData Resume data to validate
|
||||||
|
*/
|
||||||
|
private validateResumeData(resumeData: ResumeData): void {
|
||||||
|
if (!resumeData.personalInfo) {
|
||||||
|
throw new Error('Invalid resume data: missing personal info');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.experience)) {
|
||||||
|
resumeData.experience = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.education)) {
|
||||||
|
resumeData.education = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.skills)) {
|
||||||
|
resumeData.skills = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.languages)) {
|
||||||
|
resumeData.languages = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.certifications)) {
|
||||||
|
resumeData.certifications = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.projects)) {
|
||||||
|
resumeData.projects = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.awards)) {
|
||||||
|
resumeData.awards = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(resumeData.references)) {
|
||||||
|
resumeData.references = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get empty resume data structure
|
||||||
|
* @returns Empty resume data object
|
||||||
|
*/
|
||||||
|
private getEmptyResumeData(): ResumeData {
|
||||||
|
return {
|
||||||
|
personalInfo: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
jobTitle: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
city: '',
|
||||||
|
country: '',
|
||||||
|
location: '',
|
||||||
|
summary: ''
|
||||||
|
},
|
||||||
|
experience: [],
|
||||||
|
education: [],
|
||||||
|
skills: [],
|
||||||
|
languages: [],
|
||||||
|
certifications: [],
|
||||||
|
projects: [],
|
||||||
|
awards: [],
|
||||||
|
references: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
347
src/app/services/theme.service.ts
Normal file
347
src/app/services/theme.service.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* Professional Theme Service
|
||||||
|
* Author: David Valera Melendez
|
||||||
|
* Email: david@valera-melendez.de
|
||||||
|
* Created: August 8, 2025
|
||||||
|
*
|
||||||
|
* Manages application theming, color schemes, and CSS custom properties
|
||||||
|
*/
|
||||||
|
import { Injectable, Inject } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface ColorScheme {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
primary: string;
|
||||||
|
primaryLight: string;
|
||||||
|
primaryDark: string;
|
||||||
|
accent: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
error: string;
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
isDark: boolean;
|
||||||
|
colors: ColorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ThemeService {
|
||||||
|
|
||||||
|
private currentThemeSubject = new BehaviorSubject<Theme>(this.getDefaultTheme());
|
||||||
|
public currentTheme$ = this.currentThemeSubject.asObservable();
|
||||||
|
|
||||||
|
private currentColorSchemeSubject = new BehaviorSubject<ColorScheme>(this.getDefaultColorScheme());
|
||||||
|
public currentColorScheme$ = this.currentColorSchemeSubject.asObservable();
|
||||||
|
|
||||||
|
// Predefined color schemes
|
||||||
|
private colorSchemes: ColorScheme[] = [
|
||||||
|
{
|
||||||
|
name: 'blue',
|
||||||
|
displayName: 'Professional Blue',
|
||||||
|
primary: '#1976d2',
|
||||||
|
primaryLight: '#42a5f5',
|
||||||
|
primaryDark: '#1565c0',
|
||||||
|
accent: '#ff9800',
|
||||||
|
success: '#4caf50',
|
||||||
|
warning: '#ff9800',
|
||||||
|
error: '#f44336',
|
||||||
|
info: '#2196f3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'green',
|
||||||
|
displayName: 'Success Green',
|
||||||
|
primary: '#4caf50',
|
||||||
|
primaryLight: '#81c784',
|
||||||
|
primaryDark: '#388e3c',
|
||||||
|
accent: '#ff5722',
|
||||||
|
success: '#4caf50',
|
||||||
|
warning: '#ff9800',
|
||||||
|
error: '#f44336',
|
||||||
|
info: '#2196f3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'purple',
|
||||||
|
displayName: 'Creative Purple',
|
||||||
|
primary: '#9c27b0',
|
||||||
|
primaryLight: '#ba68c8',
|
||||||
|
primaryDark: '#7b1fa2',
|
||||||
|
accent: '#ff9800',
|
||||||
|
success: '#4caf50',
|
||||||
|
warning: '#ff9800',
|
||||||
|
error: '#f44336',
|
||||||
|
info: '#2196f3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'indigo',
|
||||||
|
displayName: 'Deep Indigo',
|
||||||
|
primary: '#3f51b5',
|
||||||
|
primaryLight: '#7986cb',
|
||||||
|
primaryDark: '#303f9f',
|
||||||
|
accent: '#ff4081',
|
||||||
|
success: '#4caf50',
|
||||||
|
warning: '#ff9800',
|
||||||
|
error: '#f44336',
|
||||||
|
info: '#2196f3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'teal',
|
||||||
|
displayName: 'Modern Teal',
|
||||||
|
primary: '#009688',
|
||||||
|
primaryLight: '#4db6ac',
|
||||||
|
primaryDark: '#00796b',
|
||||||
|
accent: '#ff5722',
|
||||||
|
success: '#4caf50',
|
||||||
|
warning: '#ff9800',
|
||||||
|
error: '#f44336',
|
||||||
|
info: '#2196f3'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(@Inject(DOCUMENT) private document: Document) {
|
||||||
|
this.initializeTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize theme from localStorage or default
|
||||||
|
*/
|
||||||
|
private initializeTheme(): void {
|
||||||
|
const savedTheme = this.getSavedTheme();
|
||||||
|
const savedColorScheme = this.getSavedColorScheme();
|
||||||
|
|
||||||
|
if (savedColorScheme) {
|
||||||
|
this.setColorScheme(savedColorScheme, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedTheme) {
|
||||||
|
this.setTheme(savedTheme, false);
|
||||||
|
} else {
|
||||||
|
// Apply default theme
|
||||||
|
this.applyTheme(this.currentThemeSubject.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available color schemes
|
||||||
|
*/
|
||||||
|
getColorSchemes(): ColorScheme[] {
|
||||||
|
return [...this.colorSchemes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current color scheme
|
||||||
|
*/
|
||||||
|
getCurrentColorScheme(): ColorScheme {
|
||||||
|
return this.currentColorSchemeSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set color scheme by name
|
||||||
|
*/
|
||||||
|
setColorScheme(schemeName: string, save: boolean = true): void {
|
||||||
|
const scheme = this.colorSchemes.find(s => s.name === schemeName);
|
||||||
|
if (!scheme) {
|
||||||
|
console.warn(`Color scheme '${schemeName}' not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentColorSchemeSubject.next(scheme);
|
||||||
|
this.applyColorScheme(scheme);
|
||||||
|
|
||||||
|
if (save) {
|
||||||
|
this.saveColorScheme(schemeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set theme (light/dark)
|
||||||
|
*/
|
||||||
|
setTheme(themeName: 'light' | 'dark', save: boolean = true): void {
|
||||||
|
const currentColors = this.currentColorSchemeSubject.value;
|
||||||
|
const theme: Theme = {
|
||||||
|
name: themeName,
|
||||||
|
displayName: themeName === 'dark' ? 'Dark Theme' : 'Light Theme',
|
||||||
|
isDark: themeName === 'dark',
|
||||||
|
colors: currentColors
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentThemeSubject.next(theme);
|
||||||
|
this.applyTheme(theme);
|
||||||
|
|
||||||
|
if (save) {
|
||||||
|
this.saveTheme(themeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between light and dark themes
|
||||||
|
*/
|
||||||
|
toggleTheme(): void {
|
||||||
|
const currentTheme = this.currentThemeSubject.value;
|
||||||
|
const newTheme = currentTheme.isDark ? 'light' : 'dark';
|
||||||
|
this.setTheme(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply color scheme to CSS custom properties
|
||||||
|
*/
|
||||||
|
private applyColorScheme(scheme: ColorScheme): void {
|
||||||
|
const root = this.document.documentElement;
|
||||||
|
|
||||||
|
// Apply primary colors
|
||||||
|
root.style.setProperty('--color-primary', scheme.primary);
|
||||||
|
root.style.setProperty('--color-primary-light', scheme.primaryLight);
|
||||||
|
root.style.setProperty('--color-primary-dark', scheme.primaryDark);
|
||||||
|
root.style.setProperty('--color-primary-500', scheme.primary);
|
||||||
|
root.style.setProperty('--color-primary-600', scheme.primary);
|
||||||
|
root.style.setProperty('--color-primary-700', scheme.primaryDark);
|
||||||
|
|
||||||
|
// Apply accent color
|
||||||
|
root.style.setProperty('--color-accent', scheme.accent);
|
||||||
|
root.style.setProperty('--color-accent-light', this.lightenColor(scheme.accent));
|
||||||
|
root.style.setProperty('--color-accent-dark', this.darkenColor(scheme.accent));
|
||||||
|
|
||||||
|
// Apply semantic colors
|
||||||
|
root.style.setProperty('--color-success', scheme.success);
|
||||||
|
root.style.setProperty('--color-warning', scheme.warning);
|
||||||
|
root.style.setProperty('--color-error', scheme.error);
|
||||||
|
root.style.setProperty('--color-info', scheme.info);
|
||||||
|
|
||||||
|
// Update gradients
|
||||||
|
root.style.setProperty('--gradient-primary',
|
||||||
|
`linear-gradient(135deg, ${scheme.primary} 0%, ${scheme.primaryLight} 100%)`);
|
||||||
|
root.style.setProperty('--gradient-accent',
|
||||||
|
`linear-gradient(135deg, ${scheme.accent} 0%, ${this.lightenColor(scheme.accent)} 100%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply theme (light/dark mode)
|
||||||
|
*/
|
||||||
|
private applyTheme(theme: Theme): void {
|
||||||
|
const root = this.document.documentElement;
|
||||||
|
|
||||||
|
// Set theme attribute for CSS selectors
|
||||||
|
root.setAttribute('data-theme', theme.name);
|
||||||
|
|
||||||
|
// Apply color scheme
|
||||||
|
this.applyColorScheme(theme.colors);
|
||||||
|
|
||||||
|
// Additional theme-specific properties can be set here
|
||||||
|
if (theme.isDark) {
|
||||||
|
root.classList.add('dark-theme');
|
||||||
|
root.classList.remove('light-theme');
|
||||||
|
} else {
|
||||||
|
root.classList.add('light-theme');
|
||||||
|
root.classList.remove('dark-theme');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create custom color scheme
|
||||||
|
*/
|
||||||
|
createCustomColorScheme(name: string, colors: Partial<ColorScheme>): void {
|
||||||
|
const customScheme: ColorScheme = {
|
||||||
|
name: name,
|
||||||
|
displayName: colors.displayName || name,
|
||||||
|
primary: colors.primary || '#1976d2',
|
||||||
|
primaryLight: colors.primaryLight || this.lightenColor(colors.primary || '#1976d2'),
|
||||||
|
primaryDark: colors.primaryDark || this.darkenColor(colors.primary || '#1976d2'),
|
||||||
|
accent: colors.accent || '#ff9800',
|
||||||
|
success: colors.success || '#4caf50',
|
||||||
|
warning: colors.warning || '#ff9800',
|
||||||
|
error: colors.error || '#f44336',
|
||||||
|
info: colors.info || '#2196f3'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to available schemes
|
||||||
|
const existingIndex = this.colorSchemes.findIndex(s => s.name === name);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
this.colorSchemes[existingIndex] = customScheme;
|
||||||
|
} else {
|
||||||
|
this.colorSchemes.push(customScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset to default theme and colors
|
||||||
|
*/
|
||||||
|
resetToDefault(): void {
|
||||||
|
this.setColorScheme('blue');
|
||||||
|
this.setTheme('light');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility: Lighten a hex color
|
||||||
|
*/
|
||||||
|
private lightenColor(hex: string, percent: number = 20): string {
|
||||||
|
const num = parseInt(hex.replace('#', ''), 16);
|
||||||
|
const amt = Math.round(2.55 * percent);
|
||||||
|
const R = (num >> 16) + amt;
|
||||||
|
const G = (num >> 8 & 0x00FF) + amt;
|
||||||
|
const B = (num & 0x0000FF) + amt;
|
||||||
|
return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 +
|
||||||
|
(G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +
|
||||||
|
(B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility: Darken a hex color
|
||||||
|
*/
|
||||||
|
private darkenColor(hex: string, percent: number = 20): string {
|
||||||
|
const num = parseInt(hex.replace('#', ''), 16);
|
||||||
|
const amt = Math.round(2.55 * percent);
|
||||||
|
const R = (num >> 16) - amt;
|
||||||
|
const G = (num >> 8 & 0x00FF) - amt;
|
||||||
|
const B = (num & 0x0000FF) - amt;
|
||||||
|
return '#' + (0x1000000 + (R > 255 ? 255 : R < 0 ? 0 : R) * 0x10000 +
|
||||||
|
(G > 255 ? 255 : G < 0 ? 0 : G) * 0x100 +
|
||||||
|
(B > 255 ? 255 : B < 0 ? 0 : B)).toString(16).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage methods
|
||||||
|
private getSavedTheme(): 'light' | 'dark' | null {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
return localStorage.getItem('app-theme') as 'light' | 'dark' | null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTheme(theme: 'light' | 'dark'): void {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('app-theme', theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSavedColorScheme(): string | null {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
return localStorage.getItem('app-color-scheme');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveColorScheme(scheme: string): void {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('app-color-scheme', scheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultTheme(): Theme {
|
||||||
|
return {
|
||||||
|
name: 'light',
|
||||||
|
displayName: 'Light Theme',
|
||||||
|
isDark: false,
|
||||||
|
colors: this.getDefaultColorScheme()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultColorScheme(): ColorScheme {
|
||||||
|
return this.colorSchemes[0]; // Professional Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/app/utils/api-endpoints.ts
Normal file
234
src/app/utils/api-endpoints.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* API Endpoints Configuration
|
||||||
|
* Professional Angular Resume Builder - Enterprise API Management
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized API endpoint management for professional organization
|
||||||
|
*/
|
||||||
|
export class ApiEndpoints {
|
||||||
|
// Base URLs
|
||||||
|
private static readonly API_BASE = environment.apiUrl;
|
||||||
|
private static readonly AUTH_BASE = environment.authApiUrl;
|
||||||
|
|
||||||
|
// Authentication Endpoints
|
||||||
|
static readonly AUTH = {
|
||||||
|
LOGIN: `${ApiEndpoints.AUTH_BASE}/login`,
|
||||||
|
REGISTER: `${ApiEndpoints.AUTH_BASE}/register`,
|
||||||
|
LOGOUT: `${ApiEndpoints.AUTH_BASE}/logout`,
|
||||||
|
REFRESH_TOKEN: `${ApiEndpoints.AUTH_BASE}/refresh`,
|
||||||
|
PROFILE: `${ApiEndpoints.AUTH_BASE}/profile`,
|
||||||
|
|
||||||
|
// Password Management
|
||||||
|
PASSWORD_RESET: {
|
||||||
|
REQUEST: `${ApiEndpoints.AUTH_BASE}/password-reset`,
|
||||||
|
CONFIRM: `${ApiEndpoints.AUTH_BASE}/password-reset/confirm`
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two-Factor Authentication
|
||||||
|
TWO_FACTOR: {
|
||||||
|
SETUP: `${ApiEndpoints.AUTH_BASE}/2fa/setup`,
|
||||||
|
VERIFY: `${ApiEndpoints.AUTH_BASE}/2fa/verify`,
|
||||||
|
DISABLE: `${ApiEndpoints.AUTH_BASE}/2fa/disable`,
|
||||||
|
BACKUP_CODES: `${ApiEndpoints.AUTH_BASE}/2fa/backup-codes`
|
||||||
|
},
|
||||||
|
|
||||||
|
// Session Management
|
||||||
|
SESSION: {
|
||||||
|
INFO: `${ApiEndpoints.AUTH_BASE}/session/info`,
|
||||||
|
REFRESH: `${ApiEndpoints.AUTH_BASE}/session/refresh`,
|
||||||
|
TERMINATE_ALL: `${ApiEndpoints.AUTH_BASE}/session/terminate-all`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resume Builder API Endpoints
|
||||||
|
static readonly RESUME = {
|
||||||
|
// Resume CRUD Operations
|
||||||
|
LIST: `${ApiEndpoints.API_BASE}/resumes`,
|
||||||
|
CREATE: `${ApiEndpoints.API_BASE}/resumes`,
|
||||||
|
GET: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}`,
|
||||||
|
UPDATE: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}`,
|
||||||
|
DELETE: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}`,
|
||||||
|
DUPLICATE: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/duplicate`,
|
||||||
|
|
||||||
|
// Resume Export/Import
|
||||||
|
EXPORT: {
|
||||||
|
PDF: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/export/pdf`,
|
||||||
|
DOCX: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/export/docx`,
|
||||||
|
JSON: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/export/json`,
|
||||||
|
HTML: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/export/html`
|
||||||
|
},
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
TEMPLATES: {
|
||||||
|
LIST: `${ApiEndpoints.API_BASE}/resume-templates`,
|
||||||
|
GET: (id: string) => `${ApiEndpoints.API_BASE}/resume-templates/${id}`,
|
||||||
|
PREVIEW: (id: string) => `${ApiEndpoints.API_BASE}/resume-templates/${id}/preview`
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sharing & Collaboration
|
||||||
|
SHARING: {
|
||||||
|
GET_LINK: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/share`,
|
||||||
|
UPDATE_PERMISSIONS: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/share/permissions`,
|
||||||
|
REVOKE_ACCESS: (id: string) => `${ApiEndpoints.API_BASE}/resumes/${id}/share/revoke`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// User Management Endpoints
|
||||||
|
static readonly USER = {
|
||||||
|
PROFILE: `${ApiEndpoints.API_BASE}/users/profile`,
|
||||||
|
UPDATE_PROFILE: `${ApiEndpoints.API_BASE}/users/profile`,
|
||||||
|
CHANGE_PASSWORD: `${ApiEndpoints.API_BASE}/users/change-password`,
|
||||||
|
DELETE_ACCOUNT: `${ApiEndpoints.API_BASE}/users/account`,
|
||||||
|
|
||||||
|
// User Preferences
|
||||||
|
PREFERENCES: {
|
||||||
|
GET: `${ApiEndpoints.API_BASE}/users/preferences`,
|
||||||
|
UPDATE: `${ApiEndpoints.API_BASE}/users/preferences`,
|
||||||
|
RESET: `${ApiEndpoints.API_BASE}/users/preferences/reset`
|
||||||
|
},
|
||||||
|
|
||||||
|
// User Activity
|
||||||
|
ACTIVITY: {
|
||||||
|
LIST: `${ApiEndpoints.API_BASE}/users/activity`,
|
||||||
|
CLEAR: `${ApiEndpoints.API_BASE}/users/activity/clear`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// File Management Endpoints
|
||||||
|
static readonly FILES = {
|
||||||
|
UPLOAD: `${ApiEndpoints.API_BASE}/files/upload`,
|
||||||
|
DOWNLOAD: (id: string) => `${ApiEndpoints.API_BASE}/files/${id}/download`,
|
||||||
|
DELETE: (id: string) => `${ApiEndpoints.API_BASE}/files/${id}`,
|
||||||
|
|
||||||
|
// Image Processing
|
||||||
|
IMAGES: {
|
||||||
|
UPLOAD: `${ApiEndpoints.API_BASE}/files/images/upload`,
|
||||||
|
RESIZE: (id: string) => `${ApiEndpoints.API_BASE}/files/images/${id}/resize`,
|
||||||
|
OPTIMIZE: (id: string) => `${ApiEndpoints.API_BASE}/files/images/${id}/optimize`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analytics & Reporting Endpoints
|
||||||
|
static readonly ANALYTICS = {
|
||||||
|
DASHBOARD: `${ApiEndpoints.API_BASE}/analytics/dashboard`,
|
||||||
|
RESUME_STATS: (id: string) => `${ApiEndpoints.API_BASE}/analytics/resumes/${id}/stats`,
|
||||||
|
USER_STATS: `${ApiEndpoints.API_BASE}/analytics/user/stats`,
|
||||||
|
EXPORT_DATA: `${ApiEndpoints.API_BASE}/analytics/export`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin Endpoints (Role-based access)
|
||||||
|
static readonly ADMIN = {
|
||||||
|
USERS: {
|
||||||
|
LIST: `${ApiEndpoints.API_BASE}/admin/users`,
|
||||||
|
GET: (id: string) => `${ApiEndpoints.API_BASE}/admin/users/${id}`,
|
||||||
|
UPDATE: (id: string) => `${ApiEndpoints.API_BASE}/admin/users/${id}`,
|
||||||
|
DELETE: (id: string) => `${ApiEndpoints.API_BASE}/admin/users/${id}`,
|
||||||
|
SUSPEND: (id: string) => `${ApiEndpoints.API_BASE}/admin/users/${id}/suspend`,
|
||||||
|
ACTIVATE: (id: string) => `${ApiEndpoints.API_BASE}/admin/users/${id}/activate`
|
||||||
|
},
|
||||||
|
|
||||||
|
SYSTEM: {
|
||||||
|
HEALTH: `${ApiEndpoints.API_BASE}/admin/system/health`,
|
||||||
|
LOGS: `${ApiEndpoints.API_BASE}/admin/system/logs`,
|
||||||
|
METRICS: `${ApiEndpoints.API_BASE}/admin/system/metrics`,
|
||||||
|
BACKUP: `${ApiEndpoints.API_BASE}/admin/system/backup`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility Methods for Dynamic Endpoints
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build query string from parameters
|
||||||
|
*/
|
||||||
|
static buildQuery(params: Record<string, any>): string {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined && value !== '') {
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `?${queryString}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build endpoint with query parameters
|
||||||
|
*/
|
||||||
|
static withQuery(endpoint: string, params: Record<string, any>): string {
|
||||||
|
return endpoint + ApiEndpoints.buildQuery(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate endpoint URL format
|
||||||
|
*/
|
||||||
|
static isValidEndpoint(endpoint: string): boolean {
|
||||||
|
try {
|
||||||
|
new URL(endpoint);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get environment-specific base URL
|
||||||
|
*/
|
||||||
|
static getBaseUrl(type: 'api' | 'auth' = 'api'): string {
|
||||||
|
return type === 'auth' ? ApiEndpoints.AUTH_BASE : ApiEndpoints.API_BASE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe endpoint builder for enhanced development experience
|
||||||
|
*/
|
||||||
|
export type EndpointBuilder = {
|
||||||
|
[K in keyof typeof ApiEndpoints]: typeof ApiEndpoints[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP Method types for API requests
|
||||||
|
*/
|
||||||
|
export enum HttpMethod {
|
||||||
|
GET = 'GET',
|
||||||
|
POST = 'POST',
|
||||||
|
PUT = 'PUT',
|
||||||
|
PATCH = 'PATCH',
|
||||||
|
DELETE = 'DELETE'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Request configuration interface
|
||||||
|
*/
|
||||||
|
export interface ApiRequestConfig {
|
||||||
|
endpoint: string;
|
||||||
|
method: HttpMethod;
|
||||||
|
body?: any;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Professional API endpoint documentation
|
||||||
|
*/
|
||||||
|
export const API_DOCUMENTATION = {
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Professional Resume Builder API Endpoints',
|
||||||
|
baseUrl: environment.apiUrl,
|
||||||
|
authUrl: environment.authApiUrl,
|
||||||
|
supportedFormats: ['JSON', 'PDF', 'DOCX', 'HTML'],
|
||||||
|
authentication: 'JWT Bearer Token',
|
||||||
|
rateLimit: '1000 requests per hour',
|
||||||
|
pagination: 'Cursor-based pagination',
|
||||||
|
errorFormat: 'RFC 7807 Problem Details'
|
||||||
|
} as const;
|
||||||
25
src/app/utils/index.ts
Normal file
25
src/app/utils/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Utils Module Exports
|
||||||
|
* Professional Angular Resume Builder - Utility Functions
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
// API Endpoints Utility
|
||||||
|
export {
|
||||||
|
ApiEndpoints,
|
||||||
|
HttpMethod,
|
||||||
|
ApiRequestConfig,
|
||||||
|
API_DOCUMENTATION,
|
||||||
|
EndpointBuilder
|
||||||
|
} from './api-endpoints';
|
||||||
|
|
||||||
|
// HTTP Service
|
||||||
|
export { HttpService } from '../services/http.service';
|
||||||
|
|
||||||
|
// Future utility exports can be added here
|
||||||
|
// export { ValidationUtils } from './validation';
|
||||||
|
// export { FormatUtils } from './format';
|
||||||
|
// export { CacheUtils } from './cache';
|
||||||
73
src/environments/environment.prod.ts
Normal file
73
src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Production Environment Configuration
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiUrl: 'https://api.valera-melendez.de',
|
||||||
|
authApiUrl: 'https://api.valera-melendez.de/auth',
|
||||||
|
appName: 'Resume Builder',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
|
||||||
|
// Authentication Configuration
|
||||||
|
auth: {
|
||||||
|
tokenRefreshThreshold: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||||
|
sessionTimeout: 8 * 60 * 60 * 1000, // 8 hours in milliseconds (shorter for production)
|
||||||
|
maxLoginAttempts: 3, // More restrictive in production
|
||||||
|
lockoutDuration: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||||
|
enableRememberMe: true,
|
||||||
|
enableTwoFactor: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Security Configuration
|
||||||
|
security: {
|
||||||
|
enableHttps: true,
|
||||||
|
enableCsrf: true,
|
||||||
|
contentSecurityPolicy: true,
|
||||||
|
strictTransportSecurity: true,
|
||||||
|
// Encryption Configuration - CHANGE THESE IN PRODUCTION!
|
||||||
|
encryptionKey: 'DavidValeraMelendez2025SecurePasswordTransmissionProdKey32Plus!',
|
||||||
|
encryptionAlgorithm: 'AES-256-CBC',
|
||||||
|
keySize: 256,
|
||||||
|
iterations: 10000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Feature Flags
|
||||||
|
features: {
|
||||||
|
socialLogin: true, // Enable in production
|
||||||
|
exportToPdf: true,
|
||||||
|
multiLanguage: true, // Enable in production
|
||||||
|
darkMode: true,
|
||||||
|
advancedTemplates: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Demo Configuration (Disabled in Production)
|
||||||
|
demo: {
|
||||||
|
enabled: false,
|
||||||
|
credentials: {
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logging Configuration
|
||||||
|
logging: {
|
||||||
|
level: 'error', // Only log errors in production
|
||||||
|
enableConsoleLogging: false,
|
||||||
|
enableRemoteLogging: true,
|
||||||
|
logApiErrors: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Performance Configuration
|
||||||
|
performance: {
|
||||||
|
enableServiceWorker: true, // Enable PWA features in production
|
||||||
|
enableLazyLoading: true,
|
||||||
|
enableCompression: true,
|
||||||
|
cacheTimeout: 60 * 60 * 1000 // 1 hour
|
||||||
|
}
|
||||||
|
};
|
||||||
73
src/environments/environment.ts
Normal file
73
src/environments/environment.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Development Environment Configuration
|
||||||
|
* Professional Angular Resume Builder - Enterprise Auth System
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiUrl: 'http://localhost:3001',
|
||||||
|
authApiUrl: 'http://localhost:3001/auth',
|
||||||
|
appName: 'Resume Builder',
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
|
||||||
|
// Authentication Configuration
|
||||||
|
auth: {
|
||||||
|
tokenRefreshThreshold: 5 * 60 * 1000, // 5 minutes in milliseconds
|
||||||
|
sessionTimeout: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
|
||||||
|
maxLoginAttempts: 5,
|
||||||
|
lockoutDuration: 15 * 60 * 1000, // 15 minutes in milliseconds
|
||||||
|
enableRememberMe: true,
|
||||||
|
enableTwoFactor: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Security Configuration
|
||||||
|
security: {
|
||||||
|
enableHttps: true,
|
||||||
|
enableCsrf: true,
|
||||||
|
contentSecurityPolicy: true,
|
||||||
|
strictTransportSecurity: true,
|
||||||
|
// Encryption Configuration
|
||||||
|
encryptionKey: 'DavidValeraMelendez2025SecurePasswordTransmissionDevKey32Chars!',
|
||||||
|
encryptionAlgorithm: 'AES-256-CBC',
|
||||||
|
keySize: 256,
|
||||||
|
iterations: 10000
|
||||||
|
},
|
||||||
|
|
||||||
|
// Feature Flags
|
||||||
|
features: {
|
||||||
|
socialLogin: false,
|
||||||
|
exportToPdf: true,
|
||||||
|
multiLanguage: false,
|
||||||
|
darkMode: true,
|
||||||
|
advancedTemplates: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Demo Configuration (Development Only)
|
||||||
|
demo: {
|
||||||
|
enabled: true,
|
||||||
|
credentials: {
|
||||||
|
email: 'demo@example.com',
|
||||||
|
password: 'DemoPassword123!'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logging Configuration
|
||||||
|
logging: {
|
||||||
|
level: 'warn', // Reduced logging for production readiness
|
||||||
|
enableConsoleLogging: true,
|
||||||
|
enableRemoteLogging: false,
|
||||||
|
logApiErrors: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Performance Configuration
|
||||||
|
performance: {
|
||||||
|
enableServiceWorker: false,
|
||||||
|
enableLazyLoading: true,
|
||||||
|
enableCompression: false,
|
||||||
|
cacheTimeout: 30 * 60 * 1000 // 30 minutes
|
||||||
|
}
|
||||||
|
};
|
||||||
19
src/index.html
Normal file
19
src/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Professional Resume Builder - David Valera Melendez</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Professional Resume Builder created by David Valera Melendez - Made in Germany">
|
||||||
|
<meta name="author" content="David Valera Melendez">
|
||||||
|
<meta name="keywords" content="resume, cv, curriculum vitae, professional, builder, David Valera Melendez, Germany, Angular, Material UI">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="mat-typography">
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
src/main.ts
Normal file
65
src/main.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Main Application Entry Point
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { importProvidersFrom } from '@angular/core';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatStepperModule } from '@angular/material/stepper';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatNativeDateModule } from '@angular/material/core';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import { routes } from './app/app.routes';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
import { jwtInterceptor } from './app/interceptors/jwt-interceptor.function';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap the Angular application with providers
|
||||||
|
* Configures routing, animations, HTTP client, and Material UI modules
|
||||||
|
*/
|
||||||
|
bootstrapApplication(AppComponent, {
|
||||||
|
providers: [
|
||||||
|
provideRouter(routes),
|
||||||
|
provideAnimations(),
|
||||||
|
provideHttpClient(withInterceptors([jwtInterceptor])),
|
||||||
|
importProvidersFrom(
|
||||||
|
// Angular Material Modules
|
||||||
|
MatToolbarModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatStepperModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatNativeDateModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatMenuModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}).catch(err => console.error('Application failed to start:', err));
|
||||||
394
src/styles.scss
Normal file
394
src/styles.scss
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* Global Styles
|
||||||
|
* Professional Angular Resume Builder
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @created 2025-08-08
|
||||||
|
* @location Made in Germany 🇩🇪
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Import Material Theming */
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
/* Import Professional Color System */
|
||||||
|
@import 'styles/variables';
|
||||||
|
@import 'styles/colors.scss';
|
||||||
|
|
||||||
|
/* Define the custom theme */
|
||||||
|
@include mat.core();
|
||||||
|
|
||||||
|
/* Define custom color palettes using SCSS variables */
|
||||||
|
$david-valera-primary: mat.define-palette(mat.$blue-palette, 500, 100, 900);
|
||||||
|
$david-valera-accent: mat.define-palette(mat.$orange-palette, 500, 200, 800);
|
||||||
|
$david-valera-warn: mat.define-palette(mat.$red-palette);
|
||||||
|
|
||||||
|
/* Create the theme object */
|
||||||
|
$david-valera-theme: mat.define-light-theme((
|
||||||
|
color: (
|
||||||
|
primary: $david-valera-primary,
|
||||||
|
accent: $david-valera-accent,
|
||||||
|
warn: $david-valera-warn,
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
/* Apply the theme */
|
||||||
|
@include mat.all-component-themes($david-valera-theme);
|
||||||
|
|
||||||
|
/* Global Styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Roboto', 'Helvetica Neue', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional Typography */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-0 { margin-top: 0; }
|
||||||
|
.mt-1 { margin-top: 0.25rem; }
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
.mt-3 { margin-top: 1rem; }
|
||||||
|
.mt-4 { margin-top: 1.5rem; }
|
||||||
|
.mt-5 { margin-top: 3rem; }
|
||||||
|
|
||||||
|
.mb-0 { margin-bottom: 0; }
|
||||||
|
.mb-1 { margin-bottom: 0.25rem; }
|
||||||
|
.mb-2 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-3 { margin-bottom: 1rem; }
|
||||||
|
.mb-4 { margin-bottom: 1.5rem; }
|
||||||
|
.mb-5 { margin-bottom: 3rem; }
|
||||||
|
|
||||||
|
.pt-0 { padding-top: 0; }
|
||||||
|
.pt-1 { padding-top: 0.25rem; }
|
||||||
|
.pt-2 { padding-top: 0.5rem; }
|
||||||
|
.pt-3 { padding-top: 1rem; }
|
||||||
|
.pt-4 { padding-top: 1.5rem; }
|
||||||
|
.pt-5 { padding-top: 3rem; }
|
||||||
|
|
||||||
|
.pb-0 { padding-bottom: 0; }
|
||||||
|
.pb-1 { padding-bottom: 0.25rem; }
|
||||||
|
.pb-2 { padding-bottom: 0.5rem; }
|
||||||
|
.pb-3 { padding-bottom: 1rem; }
|
||||||
|
.pb-4 { padding-bottom: 1.5rem; }
|
||||||
|
.pb-5 { padding-bottom: 3rem; }
|
||||||
|
|
||||||
|
/* Professional Container */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-sm {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Material UI Overrides */
|
||||||
|
.mat-toolbar {
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-card {
|
||||||
|
border-radius: var(--border-radius-lg) !important;
|
||||||
|
box-shadow: var(--color-shadow-light) 0px 2px 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-card-header .mat-card-title {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-form-field.mat-form-field-appearance-outline .mat-form-field-outline-thick {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-button, .mat-raised-button, .mat-stroked-button {
|
||||||
|
border-radius: var(--border-radius-md) !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-step-header .mat-step-icon {
|
||||||
|
background-color: var(--color-primary) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-step-header .mat-step-icon-selected {
|
||||||
|
background-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-progress-bar-fill::after {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Snackbar Styles */
|
||||||
|
.success-snackbar {
|
||||||
|
background: var(--color-success) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-snackbar {
|
||||||
|
background: var(--color-error) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-snackbar {
|
||||||
|
background: var(--color-warning) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional Color Scheme */
|
||||||
|
.primary-color { color: #1976d2; }
|
||||||
|
.secondary-color { color: #666; }
|
||||||
|
.accent-color { color: #ff9800; }
|
||||||
|
.success-color { color: #4caf50; }
|
||||||
|
.warning-color { color: #ff9800; }
|
||||||
|
.error-color { color: #f44336; }
|
||||||
|
|
||||||
|
.primary-bg { background-color: #1976d2; color: white; }
|
||||||
|
.secondary-bg { background-color: #f5f5f5; }
|
||||||
|
.accent-bg { background-color: #ff9800; color: white; }
|
||||||
|
|
||||||
|
/* Professional Gradients */
|
||||||
|
.gradient-primary {
|
||||||
|
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-secondary {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-accent {
|
||||||
|
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* David Valera Professional Branding */
|
||||||
|
.david-valera-signature {
|
||||||
|
color: #1976d2;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.made-in-germany {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional Card Styles */
|
||||||
|
.professional-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professional-card:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.professional-card h3 {
|
||||||
|
color: #1976d2;
|
||||||
|
border-bottom: 2px solid #e3f2fd;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professional-card {
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professional-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
font-size: 12pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-toolbar,
|
||||||
|
.action-bar,
|
||||||
|
.settings-header,
|
||||||
|
button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.professional-card,
|
||||||
|
.mat-card {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-color,
|
||||||
|
.accent-color {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus Styles for Accessibility */
|
||||||
|
button:focus,
|
||||||
|
a:focus,
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid #1976d2;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High Contrast Mode Support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
body {
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-card {
|
||||||
|
border: 2px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-color {
|
||||||
|
color: #000080;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation Classes */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-left {
|
||||||
|
animation: slideInLeft 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from { opacity: 0; transform: translateX(-20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional Loading Spinner */
|
||||||
|
.professional-spinner {
|
||||||
|
border: 3px solid #e0e0e0;
|
||||||
|
border-top: 3px solid #1976d2;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
308
src/styles/_variables.scss
Normal file
308
src/styles/_variables.scss
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* SCSS Color Variables
|
||||||
|
* Author: David Valera Melendez
|
||||||
|
* Email: david@valera-melendez.de
|
||||||
|
* Created: August 8, 2025
|
||||||
|
*
|
||||||
|
* SCSS variables for use in component stylesheets and build-time processing
|
||||||
|
* These work alongside CSS custom properties for maximum flexibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
PRIMARY BRAND COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$primary: #1976d2;
|
||||||
|
$primary-light: #42a5f5;
|
||||||
|
$primary-dark: #1565c0;
|
||||||
|
$primary-50: #e3f2fd;
|
||||||
|
$primary-100: #bbdefb;
|
||||||
|
$primary-200: #90caf9;
|
||||||
|
$primary-300: #64b5f6;
|
||||||
|
$primary-400: #42a5f5;
|
||||||
|
$primary-500: #1976d2;
|
||||||
|
$primary-600: #1976d2;
|
||||||
|
$primary-700: #1565c0;
|
||||||
|
$primary-800: #0d47a1;
|
||||||
|
$primary-900: #0d47a1;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
ACCENT COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$accent: #ff9800;
|
||||||
|
$accent-light: #ffb74d;
|
||||||
|
$accent-dark: #f57f17;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SEMANTIC COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$success: #4caf50;
|
||||||
|
$success-light: #81c784;
|
||||||
|
$success-dark: #388e3c;
|
||||||
|
|
||||||
|
$warning: #ff9800;
|
||||||
|
$warning-light: #ffb74d;
|
||||||
|
$warning-dark: #f57f17;
|
||||||
|
|
||||||
|
$error: #f44336;
|
||||||
|
$error-light: #ef5350;
|
||||||
|
$error-dark: #c62828;
|
||||||
|
|
||||||
|
$info: #2196f3;
|
||||||
|
$info-light: #64b5f6;
|
||||||
|
$info-dark: #1976d2;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
NEUTRAL COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$text-primary: #212121;
|
||||||
|
$text-secondary: #757575;
|
||||||
|
$text-disabled: #bdbdbd;
|
||||||
|
$text-hint: #9e9e9e;
|
||||||
|
$text-muted: #555555;
|
||||||
|
$text-dark: #444444;
|
||||||
|
$text-light: #999999;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BACKGROUND COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$background: #fafafa;
|
||||||
|
$background-paper: #ffffff;
|
||||||
|
$background-default: #f5f5f5;
|
||||||
|
$background-dialog: #ffffff;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SURFACE COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$surface: #ffffff;
|
||||||
|
$surface-variant: #f5f5f5;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BORDER COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$border: #e0e0e0;
|
||||||
|
$border-light: #f5f5f5;
|
||||||
|
$border-dark: #bdbdbd;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SHADOW COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$shadow-light: rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-medium: rgba(0, 0, 0, 0.15);
|
||||||
|
$shadow-dark: rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
GRADIENT DEFINITIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$gradient-primary: linear-gradient(135deg, #{$primary} 0%, #{$primary-light} 100%);
|
||||||
|
$gradient-accent: linear-gradient(135deg, #{$accent} 0%, #{$accent-light} 100%);
|
||||||
|
$gradient-success: linear-gradient(135deg, #{$success} 0%, #{$success-light} 100%);
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SPACING SYSTEM
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$spacing-xs: 0.25rem; // 4px
|
||||||
|
$spacing-sm: 0.5rem; // 8px
|
||||||
|
$spacing-md: 1rem; // 16px
|
||||||
|
$spacing-lg: 1.5rem; // 24px
|
||||||
|
$spacing-xl: 2rem; // 32px
|
||||||
|
$spacing-xxl: 3rem; // 48px
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BORDER RADIUS SYSTEM
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$border-radius-sm: 4px;
|
||||||
|
$border-radius-md: 8px;
|
||||||
|
$border-radius-lg: 12px;
|
||||||
|
$border-radius-xl: 16px;
|
||||||
|
$border-radius-round: 50%;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BREAKPOINTS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$breakpoint-xs: 576px;
|
||||||
|
$breakpoint-sm: 768px;
|
||||||
|
$breakpoint-md: 992px;
|
||||||
|
$breakpoint-lg: 1200px;
|
||||||
|
$breakpoint-xl: 1440px;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Z-INDEX LAYERS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$z-dropdown: 1000;
|
||||||
|
$z-sticky: 1010;
|
||||||
|
$z-fixed: 1020;
|
||||||
|
$z-modal-backdrop: 1030;
|
||||||
|
$z-modal: 1040;
|
||||||
|
$z-popover: 1050;
|
||||||
|
$z-tooltip: 1060;
|
||||||
|
$z-toast: 1070;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TRANSITIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$transition-fast: 0.15s ease;
|
||||||
|
$transition-normal: 0.2s ease;
|
||||||
|
$transition-slow: 0.3s ease;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TYPOGRAPHY SCALE
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$font-size-xs: 0.75rem; // 12px
|
||||||
|
$font-size-sm: 0.875rem; // 14px
|
||||||
|
$font-size-base: 1rem; // 16px
|
||||||
|
$font-size-lg: 1.125rem; // 18px
|
||||||
|
$font-size-xl: 1.25rem; // 20px
|
||||||
|
$font-size-xxl: 1.5rem; // 24px
|
||||||
|
$font-size-xxxl: 2rem; // 32px
|
||||||
|
|
||||||
|
$font-weight-light: 300;
|
||||||
|
$font-weight-normal: 400;
|
||||||
|
$font-weight-medium: 500;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
$font-weight-bold: 700;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
PROFESSIONAL SHADOW SYSTEM
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
$shadow-xs: 0 1px 2px 0 $shadow-light;
|
||||||
|
$shadow-sm: 0 1px 3px 0 $shadow-light, 0 1px 2px 0 $shadow-light;
|
||||||
|
$shadow-md: 0 4px 6px -1px $shadow-light, 0 2px 4px -1px $shadow-light;
|
||||||
|
$shadow-lg: 0 10px 15px -3px $shadow-light, 0 4px 6px -2px $shadow-light;
|
||||||
|
$shadow-xl: 0 20px 25px -5px $shadow-light, 0 10px 10px -5px $shadow-light;
|
||||||
|
$shadow-2xl: 0 25px 50px -12px $shadow-dark;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
DARK THEME COLORS (SCSS Variables)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
// Dark theme primary colors
|
||||||
|
$dark-primary: #42a5f5;
|
||||||
|
$dark-primary-light: #64b5f6;
|
||||||
|
$dark-primary-dark: #1976d2;
|
||||||
|
|
||||||
|
// Dark theme text colors
|
||||||
|
$dark-text-primary: #ffffff;
|
||||||
|
$dark-text-secondary: #b0bec5;
|
||||||
|
$dark-text-disabled: #616161;
|
||||||
|
$dark-text-hint: #757575;
|
||||||
|
|
||||||
|
// Dark theme backgrounds
|
||||||
|
$dark-background: #121212;
|
||||||
|
$dark-background-paper: #1e1e1e;
|
||||||
|
$dark-background-default: #202020;
|
||||||
|
$dark-background-dialog: #2d2d2d;
|
||||||
|
|
||||||
|
// Dark theme surfaces
|
||||||
|
$dark-surface: #1e1e1e;
|
||||||
|
$dark-surface-variant: #2d2d2d;
|
||||||
|
|
||||||
|
// Dark theme borders
|
||||||
|
$dark-border: #404040;
|
||||||
|
$dark-border-light: #2d2d2d;
|
||||||
|
$dark-border-dark: #555555;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
PROFESSIONAL COLOR FUNCTIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/// Lighten a color by a percentage
|
||||||
|
/// @param {Color} $color - The color to lighten
|
||||||
|
/// @param {Number} $percentage - The percentage to lighten by
|
||||||
|
/// @return {Color} - The lightened color
|
||||||
|
@function lighten-color($color, $percentage) {
|
||||||
|
@return lighten($color, $percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Darken a color by a percentage
|
||||||
|
/// @param {Color} $color - The color to darken
|
||||||
|
/// @param {Number} $percentage - The percentage to darken by
|
||||||
|
/// @return {Color} - The darkened color
|
||||||
|
@function darken-color($color, $percentage) {
|
||||||
|
@return darken($color, $percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a color with alpha transparency
|
||||||
|
/// @param {Color} $color - The base color
|
||||||
|
/// @param {Number} $alpha - The alpha value (0-1)
|
||||||
|
/// @return {Color} - The color with alpha
|
||||||
|
@function alpha-color($color, $alpha) {
|
||||||
|
@return rgba($color, $alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
PROFESSIONAL MIXINS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/// Apply primary color scheme
|
||||||
|
@mixin primary-colors {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: var(--color-background-paper);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply accent color scheme
|
||||||
|
@mixin accent-colors {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background-color: var(--color-background-paper);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply success color scheme
|
||||||
|
@mixin success-colors {
|
||||||
|
color: var(--color-success);
|
||||||
|
background-color: var(--color-background-paper);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply professional card styling
|
||||||
|
@mixin card-style {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--color-shadow-light) 0px 2px 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply professional button styling
|
||||||
|
@mixin button-style($color: $primary) {
|
||||||
|
background-color: $color;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
transition: var(--transition-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: darken-color($color, 10%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--color-shadow-medium) 0px 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: var(--color-text-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/styles/colors.scss
Normal file
294
src/styles/colors.scss
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Professional Color Configuration System
|
||||||
|
* Author: David Valera Melendez
|
||||||
|
* Email: david@valera-melendez.de
|
||||||
|
* Created: August 8, 2025
|
||||||
|
*
|
||||||
|
* Centralized color management with CSS custom properties and SCSS variables
|
||||||
|
* Supports light/dark themes and easy color customization
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
GLOBAL COLOR VARIABLES (CSS Custom Properties)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ===== PRIMARY BRAND COLORS ===== */
|
||||||
|
--color-primary: #1976d2; /* Main brand blue */
|
||||||
|
--color-primary-light: #42a5f5; /* Lighter blue */
|
||||||
|
--color-primary-dark: #1565c0; /* Darker blue */
|
||||||
|
--color-primary-50: #e3f2fd; /* Very light blue */
|
||||||
|
--color-primary-100: #bbdefb; /* Light blue */
|
||||||
|
--color-primary-200: #90caf9; /* Medium light blue */
|
||||||
|
--color-primary-300: #64b5f6; /* Medium blue */
|
||||||
|
--color-primary-400: #42a5f5; /* Medium dark blue */
|
||||||
|
--color-primary-500: #1976d2; /* Base primary */
|
||||||
|
--color-primary-600: #1976d2; /* Primary 600 */
|
||||||
|
--color-primary-700: #1565c0; /* Primary 700 */
|
||||||
|
--color-primary-800: #0d47a1; /* Primary 800 */
|
||||||
|
--color-primary-900: #0d47a1; /* Darkest primary */
|
||||||
|
|
||||||
|
/* ===== ACCENT COLORS ===== */
|
||||||
|
--color-accent: #ff9800; /* Orange accent */
|
||||||
|
--color-accent-light: #ffb74d; /* Light orange */
|
||||||
|
--color-accent-dark: #f57f17; /* Dark orange */
|
||||||
|
|
||||||
|
/* ===== SEMANTIC COLORS ===== */
|
||||||
|
--color-success: #4caf50; /* Success green */
|
||||||
|
--color-success-light: #81c784; /* Light success */
|
||||||
|
--color-success-dark: #388e3c; /* Dark success */
|
||||||
|
|
||||||
|
--color-warning: #ff9800; /* Warning orange */
|
||||||
|
--color-warning-light: #ffb74d; /* Light warning */
|
||||||
|
--color-warning-dark: #f57f17; /* Dark warning */
|
||||||
|
|
||||||
|
--color-error: #f44336; /* Error red */
|
||||||
|
--color-error-light: #ef5350; /* Light error */
|
||||||
|
--color-error-dark: #c62828; /* Dark error */
|
||||||
|
--color-error-bg: rgba(244, 67, 54, 0.05); /* Error background */
|
||||||
|
|
||||||
|
--color-info: #2196f3; /* Info blue */
|
||||||
|
--color-info-light: #64b5f6; /* Light info */
|
||||||
|
--color-info-dark: #1976d2; /* Dark info */
|
||||||
|
|
||||||
|
/* ===== SPECIAL COLORS ===== */
|
||||||
|
--color-indigo: #4f46e5; /* Indigo */
|
||||||
|
--color-purple: #667eea; /* Purple */
|
||||||
|
--color-blue-600: #0066cc; /* Blue 600 */
|
||||||
|
--color-blue-700: #004499; /* Blue 700 */
|
||||||
|
--color-blue-400: #42a5f5; /* Blue 400 */
|
||||||
|
--color-blue-300: #64b5f6; /* Blue 300 */
|
||||||
|
--color-blue-200: #90caf9; /* Blue 200 */
|
||||||
|
--color-blue-100: #bbdefb; /* Blue 100 */
|
||||||
|
--color-blue-glow: rgba(33, 150, 243, 0.5); /* Blue glow */
|
||||||
|
--color-blue-200-glow: rgba(144, 202, 249, 0.5); /* Blue 200 glow */
|
||||||
|
--color-purple-glow-light: rgba(102, 126, 234, 0.2); /* Light purple glow */
|
||||||
|
--color-purple-glow-medium: rgba(102, 126, 234, 0.3); /* Medium purple glow */
|
||||||
|
--color-purple-glow-strong: rgba(102, 126, 234, 0.4); /* Strong purple glow */
|
||||||
|
|
||||||
|
/* ===== SOCIAL/BRAND COLORS ===== */
|
||||||
|
--color-google: #ea4335; /* Google red */
|
||||||
|
--color-google-bg: rgba(234, 67, 53, 0.04); /* Google background */
|
||||||
|
--color-microsoft: #0078d4; /* Microsoft blue */
|
||||||
|
--color-microsoft-bg: rgba(0, 120, 212, 0.04); /* Microsoft background */
|
||||||
|
--color-slate-300: #e2e8f0; /* Slate 300 */
|
||||||
|
--color-slate-50: #f8fafc; /* Slate 50 */
|
||||||
|
--color-purple-brand: #667eea; /* Purple brand */
|
||||||
|
|
||||||
|
/* ===== ERROR STATE COLORS ===== */
|
||||||
|
--color-error-bg-light: #fef2f2; /* Light error background */
|
||||||
|
--color-error-bg-medium: #fee2e2; /* Medium error background */
|
||||||
|
--color-error-border-light: #fecaca; /* Light error border */
|
||||||
|
--color-error-text-dark: #991b1b; /* Dark error text */
|
||||||
|
--gradient-error-light: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||||||
|
|
||||||
|
/* ===== WARNING STATE COLORS ===== */
|
||||||
|
--color-warning-bg-light: #fffbeb; /* Light warning background */
|
||||||
|
--color-warning-bg-medium: #fef3c7; /* Medium warning background */
|
||||||
|
--color-warning-border-light: #fde68a; /* Light warning border */
|
||||||
|
--color-warning-text: #d97706; /* Warning text */
|
||||||
|
--color-warning-text-dark: #92400e; /* Dark warning text */
|
||||||
|
--gradient-warning-light: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||||
|
|
||||||
|
/* ===== NEUTRAL COLORS ===== */
|
||||||
|
--color-text-primary: #212121; /* Primary text */
|
||||||
|
--color-text-secondary: #757575; /* Secondary text */
|
||||||
|
--color-text-disabled: #bdbdbd; /* Disabled text */
|
||||||
|
--color-text-hint: #9e9e9e; /* Hint text */
|
||||||
|
--color-text-muted: #555555; /* Muted text */
|
||||||
|
--color-text-dark: #444444; /* Dark text */
|
||||||
|
--color-text-light: #999999; /* Light text */
|
||||||
|
--color-text-white: #ffffff; /* White text */
|
||||||
|
--color-text-white-muted: rgba(255, 255, 255, 0.9); /* Muted white text */
|
||||||
|
--color-text-white-light: rgba(255, 255, 255, 0.8); /* Light white text */
|
||||||
|
--color-text-white-subtle: rgba(255, 255, 255, 0.7); /* Subtle white text */
|
||||||
|
--color-text-slate: #64748b; /* Slate text */
|
||||||
|
--color-text-slate-dark: #475569; /* Dark slate text - UPDATED */
|
||||||
|
--color-text-slate-light: #94a3b8; /* Light slate text */
|
||||||
|
--color-text-gray-900: #1a202c; /* Very dark gray */
|
||||||
|
--color-text-slate-700: #334155; /* Slate 700 */
|
||||||
|
--color-text-slate-600: #475569; /* Slate 600 */
|
||||||
|
--color-overlay-dark: rgba(30, 41, 59, 0.95); /* Dark overlay */
|
||||||
|
--color-black: #000000; /* Pure black */
|
||||||
|
|
||||||
|
/* ===== BACKGROUND COLORS ===== */
|
||||||
|
--color-background: #fafafa; /* Main background */
|
||||||
|
--color-background-paper: #ffffff; /* Card/paper background */
|
||||||
|
--color-background-default: #f5f5f5; /* Default background */
|
||||||
|
--color-background-dialog: #ffffff; /* Dialog background */
|
||||||
|
--color-background-light: #f8fafc; /* Light background */
|
||||||
|
--color-background-slate: #e2e8f0; /* Slate background */
|
||||||
|
--color-background-white-overlay: rgba(255, 255, 255, 0.95); /* White overlay */
|
||||||
|
--color-background-white-subtle: rgba(255, 255, 255, 0.15); /* Subtle white overlay */
|
||||||
|
--color-background-dark-subtle: rgba(0, 0, 0, 0.05); /* Subtle dark overlay */
|
||||||
|
|
||||||
|
/* ===== SURFACE COLORS ===== */
|
||||||
|
--color-surface: #ffffff; /* Surface color */
|
||||||
|
--color-surface-variant: #f5f5f5; /* Surface variant */
|
||||||
|
|
||||||
|
/* ===== BORDER COLORS ===== */
|
||||||
|
--color-border: #e0e0e0; /* Default border */
|
||||||
|
--color-border-light: #f5f5f5; /* Light border */
|
||||||
|
--color-border-dark: #bdbdbd; /* Dark border */
|
||||||
|
--color-border-white-subtle: rgba(255, 255, 255, 0.3); /* Subtle white border */
|
||||||
|
--color-border-dark-subtle: rgba(0, 0, 0, 0.06); /* Subtle dark border */
|
||||||
|
|
||||||
|
/* ===== SHADOW COLORS ===== */
|
||||||
|
--color-shadow-light: rgba(0, 0, 0, 0.1);
|
||||||
|
--color-shadow-medium: rgba(0, 0, 0, 0.15);
|
||||||
|
--color-shadow-dark: rgba(0, 0, 0, 0.25);
|
||||||
|
--color-shadow-subtle: rgba(0, 0, 0, 0.05);
|
||||||
|
--color-shadow-heavy: rgba(0, 0, 0, 0.3);
|
||||||
|
--color-shadow-card: rgba(0, 0, 0, 0.08);
|
||||||
|
--color-shadow-deep: rgba(0, 0, 0, 0.12);
|
||||||
|
--color-text-shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
--color-disabled-overlay: rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
/* ===== OVERLAY COLORS ===== */
|
||||||
|
--color-overlay: rgba(0, 0, 0, 0.7);
|
||||||
|
--color-overlay-light: rgba(0, 0, 0, 0.5);
|
||||||
|
--color-surface-overlay: rgba(255, 255, 255, 0.1);
|
||||||
|
--color-surface-overlay-light: rgba(255, 255, 255, 0.2);
|
||||||
|
--color-surface-overlay-medium: rgba(255, 255, 255, 0.4);
|
||||||
|
|
||||||
|
/* ===== GRADIENT COLORS ===== */
|
||||||
|
--gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
||||||
|
--gradient-accent: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-light) 100%);
|
||||||
|
--gradient-success: linear-gradient(135deg, var(--color-success) 0%, var(--color-success-light) 100%);
|
||||||
|
--gradient-background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
--gradient-progress: linear-gradient(90deg, #0066cc, #004499, #0066cc);
|
||||||
|
--gradient-progress-glow: linear-gradient(90deg, #42a5f5, #64b5f6, #90caf9, #bbdefb, #90caf9, #64b5f6);
|
||||||
|
|
||||||
|
/* ===== PROFESSIONAL SPACING ===== */
|
||||||
|
--spacing-xs: 0.25rem; /* 4px */
|
||||||
|
--spacing-sm: 0.5rem; /* 8px */
|
||||||
|
--spacing-md: 1rem; /* 16px */
|
||||||
|
--spacing-lg: 1.5rem; /* 24px */
|
||||||
|
--spacing-xl: 2rem; /* 32px */
|
||||||
|
--spacing-xxl: 3rem; /* 48px */
|
||||||
|
|
||||||
|
/* ===== BORDER RADIUS ===== */
|
||||||
|
--border-radius-sm: 4px;
|
||||||
|
--border-radius-md: 8px;
|
||||||
|
--border-radius-lg: 12px;
|
||||||
|
--border-radius-xl: 16px;
|
||||||
|
--border-radius-round: 50%;
|
||||||
|
|
||||||
|
/* ===== TRANSITIONS ===== */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
|
|
||||||
|
/* ===== Z-INDEX LAYERS ===== */
|
||||||
|
--z-index-dropdown: 1000;
|
||||||
|
--z-index-sticky: 1010;
|
||||||
|
--z-index-fixed: 1020;
|
||||||
|
--z-index-modal-backdrop: 1030;
|
||||||
|
--z-index-modal: 1040;
|
||||||
|
--z-index-popover: 1050;
|
||||||
|
--z-index-tooltip: 1060;
|
||||||
|
--z-index-toast: 1070;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
DARK THEME COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Primary colors remain the same */
|
||||||
|
--color-primary: #42a5f5; /* Lighter blue for dark theme */
|
||||||
|
--color-primary-light: #64b5f6;
|
||||||
|
--color-primary-dark: #1976d2;
|
||||||
|
|
||||||
|
/* Accent colors */
|
||||||
|
--color-accent: #ffb74d; /* Lighter orange for dark theme */
|
||||||
|
|
||||||
|
/* Text colors for dark theme */
|
||||||
|
--color-text-primary: #ffffff;
|
||||||
|
--color-text-secondary: #b0bec5;
|
||||||
|
--color-text-disabled: #616161;
|
||||||
|
--color-text-hint: #757575;
|
||||||
|
|
||||||
|
/* Background colors for dark theme */
|
||||||
|
--color-background: #121212;
|
||||||
|
--color-background-paper: #1e1e1e;
|
||||||
|
--color-background-default: #202020;
|
||||||
|
--color-background-dialog: #2d2d2d;
|
||||||
|
|
||||||
|
/* Surface colors for dark theme */
|
||||||
|
--color-surface: #1e1e1e;
|
||||||
|
--color-surface-variant: #2d2d2d;
|
||||||
|
|
||||||
|
/* Border colors for dark theme */
|
||||||
|
--color-border: #404040;
|
||||||
|
--color-border-light: #2d2d2d;
|
||||||
|
--color-border-dark: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
UTILITY CLASSES FOR COLORS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
.text-primary { color: var(--color-primary) !important; }
|
||||||
|
.text-accent { color: var(--color-accent) !important; }
|
||||||
|
.text-success { color: var(--color-success) !important; }
|
||||||
|
.text-warning { color: var(--color-warning) !important; }
|
||||||
|
.text-error { color: var(--color-error) !important; }
|
||||||
|
.text-info { color: var(--color-info) !important; }
|
||||||
|
|
||||||
|
/* Background Colors */
|
||||||
|
.bg-primary { background-color: var(--color-primary) !important; }
|
||||||
|
.bg-accent { background-color: var(--color-accent) !important; }
|
||||||
|
.bg-success { background-color: var(--color-success) !important; }
|
||||||
|
.bg-warning { background-color: var(--color-warning) !important; }
|
||||||
|
.bg-error { background-color: var(--color-error) !important; }
|
||||||
|
.bg-info { background-color: var(--color-info) !important; }
|
||||||
|
|
||||||
|
/* Gradient Backgrounds */
|
||||||
|
.bg-gradient-primary { background: var(--gradient-primary) !important; }
|
||||||
|
.bg-gradient-accent { background: var(--gradient-accent) !important; }
|
||||||
|
.bg-gradient-success { background: var(--gradient-success) !important; }
|
||||||
|
|
||||||
|
/* Border Colors */
|
||||||
|
.border-primary { border-color: var(--color-primary) !important; }
|
||||||
|
.border-accent { border-color: var(--color-accent) !important; }
|
||||||
|
.border-success { border-color: var(--color-success) !important; }
|
||||||
|
.border-warning { border-color: var(--color-warning) !important; }
|
||||||
|
.border-error { border-color: var(--color-error) !important; }
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
RESPONSIVE COLOR ADJUSTMENTS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
/* Slightly adjust colors for mobile if needed */
|
||||||
|
--color-shadow-light: rgba(0, 0, 0, 0.08);
|
||||||
|
--color-shadow-medium: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
HIGH CONTRAST MODE
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
:root {
|
||||||
|
--color-primary: #0d47a1; /* Darker primary for high contrast */
|
||||||
|
--color-text-secondary: #424242; /* Darker secondary text */
|
||||||
|
--color-border: #424242; /* Darker borders */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
REDUCED MOTION PREFERENCES
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--transition-fast: 0s;
|
||||||
|
--transition-normal: 0s;
|
||||||
|
--transition-slow: 0s;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tsconfig.app.json
Normal file
13
tsconfig.app.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
40
tsconfig.json
Normal file
40
tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@/components/*": ["src/app/components/*"],
|
||||||
|
"@/services/*": ["src/app/services/*"],
|
||||||
|
"@/models/*": ["src/app/models/*"],
|
||||||
|
"@/utils/*": ["src/app/utils/*"],
|
||||||
|
"@/shared/*": ["src/app/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tsconfig.spec.json
Normal file
13
tsconfig.spec.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user