🎯 Features implemented: - Multi-language speech recognition (EN, FR, ES, DE) - CV upload and parsing with regex-escaped skills extraction - Authelia authentication integration for n8n webhook - Complete n8n workflow for AI question generation - Real-time language switching with enhanced UI - Professional authentication modal with dual login options 🔧 Technical stack: - Angular 18 with standalone components and signals - TypeScript with strict typing and interfaces - Authelia session-based authentication - n8n workflow automation with OpenAI integration - PDF.js for CV text extraction - Web Speech API for voice recognition 🛠️ Infrastructure: - Secure authentication flow with proper error handling - Environment-based configuration for dev/prod - Comprehensive documentation and workflow templates - Clean project structure with proper git ignore rules 🔒 Security features: - Cookie-based session management with CORS - Protected n8n webhooks via Authelia - Graceful fallback to local processing - Secure redirect handling and session persistence 🚀 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
238 lines
6.4 KiB
TypeScript
238 lines
6.4 KiB
TypeScript
import { Injectable } from '@angular/core';
|
|
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
|
|
import { BehaviorSubject, Observable, throwError, of } from 'rxjs';
|
|
import { map, catchError, tap, retry } from 'rxjs/operators';
|
|
import { environment } from '../../environments/environment';
|
|
|
|
export interface AutheliaUser {
|
|
username: string;
|
|
displayName: string;
|
|
email: string;
|
|
groups: string[];
|
|
}
|
|
|
|
export interface AutheliaSession {
|
|
username: string;
|
|
displayName: string;
|
|
email: string;
|
|
groups: string[];
|
|
authenticated: boolean;
|
|
expires: string;
|
|
}
|
|
|
|
export interface AutheliaLoginRequest {
|
|
username: string;
|
|
password: string;
|
|
keepMeLoggedIn?: boolean;
|
|
targetURL?: string;
|
|
}
|
|
|
|
export interface AutheliaLoginResponse {
|
|
status: 'OK' | 'FAILED';
|
|
message?: string;
|
|
data?: {
|
|
redirect?: string;
|
|
};
|
|
}
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class AutheliaAuthService {
|
|
private readonly authBaseUrl = environment.authelia?.baseUrl || 'https://auth.gm-tech.org';
|
|
|
|
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
|
|
private userSubject = new BehaviorSubject<AutheliaUser | null>(null);
|
|
private sessionSubject = new BehaviorSubject<AutheliaSession | null>(null);
|
|
|
|
public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
|
|
public user$ = this.userSubject.asObservable();
|
|
public session$ = this.sessionSubject.asObservable();
|
|
|
|
constructor(private http: HttpClient) {
|
|
this.checkAuthenticationStatus();
|
|
}
|
|
|
|
/**
|
|
* Check if user is currently authenticated with Authelia
|
|
*/
|
|
public checkAuthenticationStatus(): Observable<boolean> {
|
|
const verifyUrl = `${this.authBaseUrl}/api/verify`;
|
|
|
|
return this.http.get<AutheliaSession>(verifyUrl, {
|
|
withCredentials: true,
|
|
headers: this.getHeaders()
|
|
}).pipe(
|
|
map(session => {
|
|
if (session.authenticated) {
|
|
this.sessionSubject.next(session);
|
|
this.userSubject.next({
|
|
username: session.username,
|
|
displayName: session.displayName,
|
|
email: session.email,
|
|
groups: session.groups
|
|
});
|
|
this.isAuthenticatedSubject.next(true);
|
|
console.log('✅ User authenticated with Authelia:', session.username);
|
|
return true;
|
|
} else {
|
|
this.clearSession();
|
|
return false;
|
|
}
|
|
}),
|
|
catchError((error: HttpErrorResponse) => {
|
|
console.log('❌ Authentication check failed:', error.status);
|
|
this.clearSession();
|
|
return of(false);
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Login with Authelia
|
|
*/
|
|
public login(username: string, password: string, targetURL?: string): Observable<AutheliaLoginResponse> {
|
|
const loginUrl = `${this.authBaseUrl}/api/firstfactor`;
|
|
|
|
const loginData: AutheliaLoginRequest = {
|
|
username,
|
|
password,
|
|
keepMeLoggedIn: true,
|
|
targetURL: targetURL || environment.n8nWebhookUrl
|
|
};
|
|
|
|
return this.http.post<AutheliaLoginResponse>(loginUrl, loginData, {
|
|
withCredentials: true,
|
|
headers: this.getHeaders()
|
|
}).pipe(
|
|
tap(response => {
|
|
if (response.status === 'OK') {
|
|
console.log('✅ Login successful');
|
|
// Refresh authentication status
|
|
this.checkAuthenticationStatus().subscribe();
|
|
} else {
|
|
console.log('❌ Login failed:', response.message);
|
|
}
|
|
}),
|
|
catchError(this.handleError)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Logout from Authelia
|
|
*/
|
|
public logout(): Observable<any> {
|
|
const logoutUrl = `${this.authBaseUrl}/api/logout`;
|
|
|
|
return this.http.post(logoutUrl, {}, {
|
|
withCredentials: true,
|
|
headers: this.getHeaders()
|
|
}).pipe(
|
|
tap(() => {
|
|
console.log('✅ Logout successful');
|
|
this.clearSession();
|
|
}),
|
|
catchError(this.handleError)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get authentication headers for API requests
|
|
*/
|
|
public getAuthHeaders(): HttpHeaders {
|
|
return this.getHeaders();
|
|
}
|
|
|
|
/**
|
|
* Make authenticated request to protected resource
|
|
*/
|
|
public makeAuthenticatedRequest<T>(url: string, data?: any, method: 'GET' | 'POST' = 'POST'): Observable<T> {
|
|
const options = {
|
|
withCredentials: true,
|
|
headers: this.getHeaders()
|
|
};
|
|
|
|
if (method === 'GET') {
|
|
return this.http.get<T>(url, options).pipe(
|
|
retry(1),
|
|
catchError(this.handleAuthenticatedRequestError.bind(this))
|
|
);
|
|
} else {
|
|
return this.http.post<T>(url, data, options).pipe(
|
|
retry(1),
|
|
catchError(this.handleAuthenticatedRequestError.bind(this))
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redirect to Authelia login page
|
|
*/
|
|
public redirectToLogin(targetURL?: string): void {
|
|
const returnURL = targetURL || window.location.href;
|
|
const loginURL = `${this.authBaseUrl}/?rd=${encodeURIComponent(returnURL)}`;
|
|
window.location.href = loginURL;
|
|
}
|
|
|
|
/**
|
|
* Get current authentication status
|
|
*/
|
|
public get isAuthenticated(): boolean {
|
|
return this.isAuthenticatedSubject.value;
|
|
}
|
|
|
|
/**
|
|
* Get current user
|
|
*/
|
|
public get currentUser(): AutheliaUser | null {
|
|
return this.userSubject.value;
|
|
}
|
|
|
|
/**
|
|
* Get current session
|
|
*/
|
|
public get currentSession(): AutheliaSession | null {
|
|
return this.sessionSubject.value;
|
|
}
|
|
|
|
private getHeaders(): HttpHeaders {
|
|
return new HttpHeaders({
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
});
|
|
}
|
|
|
|
private clearSession(): void {
|
|
this.isAuthenticatedSubject.next(false);
|
|
this.userSubject.next(null);
|
|
this.sessionSubject.next(null);
|
|
}
|
|
|
|
private handleAuthenticatedRequestError(error: HttpErrorResponse): Observable<never> {
|
|
if (error.status === 401 || error.status === 403) {
|
|
console.log('🔒 Authentication required for protected resource');
|
|
this.clearSession();
|
|
|
|
// Don't automatically redirect for API calls, let the calling component handle it
|
|
return throwError(() => new Error('Authentication required'));
|
|
}
|
|
|
|
return this.handleError(error);
|
|
}
|
|
|
|
private handleError(error: HttpErrorResponse): Observable<never> {
|
|
let errorMessage = 'An error occurred';
|
|
|
|
if (error.error instanceof ErrorEvent) {
|
|
// Client-side error
|
|
errorMessage = `Error: ${error.error.message}`;
|
|
} else {
|
|
// Server-side error
|
|
errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
|
|
}
|
|
|
|
console.error('❌ Authelia service error:', errorMessage);
|
|
return throwError(() => new Error(errorMessage));
|
|
}
|
|
} |