Add initial Angular webclient application with authentication, route guards, and UI components

This commit is contained in:
2026-04-24 18:31:10 +02:00
parent ebc27a0923
commit e89f3fc72c
46 changed files with 10056 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]
};

View File

View File

@@ -0,0 +1,2 @@
<app-navbar></app-navbar>
<router-outlet></router-outlet>

View File

@@ -0,0 +1,53 @@
import { Routes } from '@angular/router';
import { authOnlyCanActivate, authOnlyCanMatch } from './guards/auth-only.guard';
import { guestOnlyCanActivate, guestOnlyCanMatch } from './guards/guest-only.guard';
import { Login } from './pages/auth/login/login';
import { Profile } from './pages/profile/profile';
import { Home } from './pages/home/home';
import { Register } from './pages/auth/register/register';
import { adminOnlyCanActivate, adminOnlyCanMatch } from './guards/admin-only.guard';
import {Admin} from './pages/admin/admin/admin';
export const routes: Routes = [
{
path: '',
children: [
{
path: '',
component: Home,
},
{
path: 'home',
component: Home,
},
],
},
{
path: 'register',
component: Register,
canMatch: [guestOnlyCanMatch],
canActivate: [guestOnlyCanActivate],
},
{
path: 'login',
component: Login,
canMatch: [guestOnlyCanMatch],
canActivate: [guestOnlyCanActivate],
},
{
path: 'profile',
component: Profile,
canMatch: [authOnlyCanMatch],
canActivate: [authOnlyCanActivate],
},
{
path: 'admin',
component: Admin,
canMatch: [adminOnlyCanMatch],
canActivate: [adminOnlyCanActivate],
},
{
path: '**',
redirectTo: '',
},
];

View File

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, karaforge-web');
});
});

13
webclient/src/app/app.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Navbar } from './components/navbar/navbar';
@Component({
selector: 'app-root',
imports: [RouterOutlet, Navbar],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected readonly title = signal('karaforge-web');
}

View File

@@ -0,0 +1,7 @@
.dropdown-item i {
margin-right: 8px;
}
.navbar {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

View File

@@ -0,0 +1,61 @@
<nav class="navbar navbar-expand-lg navbar-light border-bottom">
<div class="container">
<a class="navbar-brand fw-bold" [routerLink]="'/'">
<img ngSrc="/logo.png" alt="logo" height="32" width="32"/>
Portail Bureau Service
</a>
<div class="ms-auto">
@if (getUser(); as user) {
<div class="dropdown">
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
id="userDropdown"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-person-circle me-2"></i>
<span class="d-none d-sm-inline">{{ user.username }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li>
<a class="dropdown-item" [routerLink]="'/profile'">
<i class="bi bi-person"></i> Profile
</a>
</li>
<li>
<a class="dropdown-item" [routerLink]="'/moulinettes'">
<i class="bi bi-person-lines-fill"></i> Liste des moulinettes
</a>
</li>
@if (authService.hasRole('Administrator')) {
<li>
<a class="dropdown-item" [routerLink]="'/admin'">
<i class="bi bi-shield-lock"></i> Administration
</a>
</li>
<li>
<a class="dropdown-item" [routerLink]="'/admin/rules'">
<i class="bi bi-signpost-split"></i> Gestion des moulinettes
</a>
</li>
}
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item" (click)="logout()">
<i class="bi bi-box-arrow-right"></i> Se déconnecter
</a>
</li>
</ul>
</div>
} @else {
<div class="btn-group gap-4">
<button class="btn btn-outline-dark" [routerLink]="'/login'">Se connecter</button>
</div>
}
</div>
</div>
</nav>

View File

@@ -0,0 +1,35 @@
import {Component, inject} from '@angular/core';
import {Router, RouterLink} from '@angular/router';
import { AuthService } from '../../services/auth/auth-service';
import {NgOptimizedImage} from "@angular/common";
@Component({
selector: 'app-navbar',
standalone: true,
imports: [
RouterLink,
NgOptimizedImage,
],
templateUrl: './navbar.html',
styleUrl: './navbar.css'
})
export class Navbar {
protected readonly authService = inject(AuthService);
private readonly router: Router = inject(Router);
logout() {
this.authService.logout().subscribe({
next: () => {
this.router.navigate(['/login'], {queryParams: {redirect: '/profile'}}).then();
},
error: (err) => {
console.error('Logout failed:', err);
}
});
}
getUser() {
return this.authService.user();
}
}

View File

@@ -0,0 +1,43 @@
import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router, ActivatedRouteSnapshot, Route } from '@angular/router';
import { AuthService } from '../services/auth/auth-service';
import { filter, map, take } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';
export const adminOnlyCanActivate: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const authService = inject(AuthService);
const router = inject(Router);
return toObservable(authService.isInitialized).pipe(
filter((initialized) => initialized),
take(1),
map(() => {
if (!authService.isLoggedIn()) {
return router.createUrlTree(['/login'], { queryParams: { redirect: router.url } });
}
if (!authService.hasRole('Administrator')) {
return router.parseUrl('/home');
}
return true;
}),
);
};
export const adminOnlyCanMatch: CanMatchFn = (route: Route) => {
const authService = inject(AuthService);
const router = inject(Router);
return toObservable(authService.isInitialized).pipe(
filter((initialized) => initialized),
take(1),
map(() => {
if (!authService.isLoggedIn()) {
return router.createUrlTree(['/login']);
}
if (!authService.hasRole('Administrator')) {
return router.parseUrl('/home');
}
return true;
}),
);
};

View File

@@ -0,0 +1,31 @@
import { inject } from '@angular/core';
import { CanActivateFn, CanMatchFn, Router, ActivatedRouteSnapshot, Route } from '@angular/router';
import { AuthService } from '../services/auth/auth-service';
import { filter, map, take } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';
export const authOnlyCanActivate: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const authService = inject(AuthService);
const router = inject(Router);
return toObservable(authService.isInitialized).pipe(
filter((initialized) => initialized),
take(1),
map(() =>
authService.isLoggedIn()
? true
: router.createUrlTree(['/login'], { queryParams: { redirect: router.url } }),
),
);
};
export const authOnlyCanMatch: CanMatchFn = (route: Route) => {
const authService = inject(AuthService);
const router = inject(Router);
return toObservable(authService.isInitialized).pipe(
filter((initialized) => initialized),
take(1),
map(() => (authService.isLoggedIn() ? true : router.createUrlTree(['/login']))),
);
};

View File

@@ -0,0 +1,27 @@
import { inject } from '@angular/core';
import { Router, CanActivateFn, CanMatchFn } from '@angular/router';
import { AuthService } from '../services/auth/auth-service';
import { filter, map, take } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';
export const guestOnlyCanActivate: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return toObservable(authService.isInitialized).pipe(
filter((initialized) => initialized),
take(1),
map(() => (authService.isLoggedIn() ? router.parseUrl('/home') : true)),
);
};
export const guestOnlyCanMatch: CanMatchFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return toObservable(authService.isInitialized).pipe(
filter((initialized) => initialized),
take(1),
map(() => (authService.isLoggedIn() ? router.parseUrl('/home') : true)),
);
};

View File

@@ -0,0 +1,45 @@
import {HttpErrorResponse, HttpInterceptorFn} from '@angular/common/http';
import {inject} from '@angular/core';
import {catchError, switchMap, throwError} from 'rxjs';
import {AuthService} from '../../services/auth/auth-service';
let isRefreshing = false;
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const authService: AuthService = inject(AuthService);
const token = authService.getAccessToken();
// Ajout de lAuthorization si on a un access token en mémoire
const authReq = token
? req.clone({setHeaders: {Authorization: `Bearer ${token}`}, withCredentials: true})
: req.clone({withCredentials: true});
return next(authReq).pipe(
catchError((error: any) => {
const is401 = error instanceof HttpErrorResponse && error.status === 401;
// si 401 et pas déjà en refresh, tente un refresh puis rejoue la requête une fois
if (is401 && !isRefreshing) {
isRefreshing = true;
return inject(AuthService).refresh().pipe(
switchMap(newToken => {
isRefreshing = false;
if (!newToken) return throwError(() => error);
const retryReq = req.clone({
setHeaders: {Authorization: `Bearer ${newToken}`},
withCredentials: true
});
return next(retryReq);
}),
catchError(err => {
isRefreshing = false;
return throwError(() => err);
})
);
}
return throwError(() => error);
})
);
};

View File

@@ -0,0 +1,4 @@
export interface Credentials {
username: string;
password: string;
}

View File

@@ -0,0 +1,8 @@
export interface User {
id: number;
firstName: string;
lastName: string;
username: string;
email: string;
role: string;
}

View File

@@ -0,0 +1 @@
<p>admin works!</p>

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-admin',
imports: [],
templateUrl: './admin.html',
styleUrl: './admin.css',
})
export class Admin {}

View File

@@ -0,0 +1,10 @@
.min-vh-100 {
min-height: 100vh;
}
.auth-card {
width: 100%;
max-width: 400px;
border-radius: 10px;
border: none;
}

View File

@@ -0,0 +1,49 @@
<div class="container d-flex justify-content-center align-items-center min-vh-100">
<div class="card auth-card shadow-sm">
<div class="card-body">
<h3 class="card-title text-center mb-4">Se connecter</h3>
<form (submit)="login()" [formGroup]="loginFormGroup">
<div class="mb-3">
<label for="username" class="form-label">Nom d'utilisateur</label>
<input
type="text"
class="form-control"
id="username"
formControlName="username"
autocomplete="username">
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<input
type="password"
class="form-control"
id="password"
formControlName="password"
autocomplete="current-password">
</div>
@if (invalidCredentials) {
<div class="alert alert-danger" role="alert">
Nom d'utilisateur ou mot de passe invalide
</div>
}
<button
type="submit"
class="btn btn-primary w-100"
[disabled]="loginFormGroup.invalid">
Se connecter
</button>
</form>
<hr class="my-4">
<p class="text-center text-muted mb-0">
Vous n'avez pas de compte ?
<a [routerLink]="'/register'">S'inscrire</a>
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
import { Component, inject, OnDestroy } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { AuthService } from '../../../services/auth/auth-service';
import { Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { Credentials } from '../../../interfaces/auth/credentials';
import { User } from '../../../interfaces/user/user';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './login.html',
styleUrl: './login.css',
})
export class Login implements OnDestroy {
private readonly formBuilder: FormBuilder = inject(FormBuilder);
private readonly authService: AuthService = inject(AuthService);
private readonly router: Router = inject(Router);
private loginSubscription: Subscription | null = null;
loginFormGroup = this.formBuilder.group({
username: ['', [Validators.required]],
password: ['', [Validators.required]],
});
invalidCredentials = false;
ngOnDestroy() {
this.loginSubscription?.unsubscribe();
}
login() {
this.loginSubscription = this.authService
.login(this.loginFormGroup.value as Credentials)
.subscribe({
next: (result: User | null | undefined) => {
console.log(result);
this.navigateHome();
},
error: (error) => {
console.log(error);
this.invalidCredentials = true;
},
});
}
navigateHome() {
this.router.navigate(['/home']).then();
}
}

View File

@@ -0,0 +1,10 @@
.min-vh-100 {
min-height: 100vh;
}
.auth-card {
width: 100%;
max-width: 520px;
border-radius: 10px;
border: none;
}

View File

@@ -0,0 +1,134 @@
<div class="container d-flex justify-content-center align-items-center min-vh-100 py-4">
<div class="card auth-card shadow-sm">
<div class="card-body">
<h3 class="card-title text-center mb-2">Inscription</h3>
<p class="text-center text-muted mb-4">Créer un nouveau compte</p>
<form [formGroup]="registerForm" (ngSubmit)="onRegister()">
<div class="mb-3">
<label for="firstName" class="form-label">Prénom</label>
<input
type="text"
class="form-control"
[class.is-invalid]="isFieldInvalid('firstName')"
id="firstName"
formControlName="firstName"
autocomplete="given-name">
@if (isFieldInvalid('firstName')) {
<div class="invalid-feedback">{{ getFieldError('firstName') }}</div>
}
</div>
<div class="mb-3">
<label for="lastName" class="form-label">Nom</label>
<input
type="text"
class="form-control"
[class.is-invalid]="isFieldInvalid('lastName')"
id="lastName"
formControlName="lastName"
autocomplete="family-name">
@if (isFieldInvalid('lastName')) {
<div class="invalid-feedback">{{ getFieldError('lastName') }}</div>
}
</div>
<div class="mb-3">
<label for="username" class="form-label">Nom d'utilisateur</label>
<input
type="text"
class="form-control"
[class.is-invalid]="isFieldInvalid('username')"
id="username"
formControlName="username"
autocomplete="username">
@if (isFieldInvalid('username')) {
<div class="invalid-feedback">{{ getFieldError('username') }}</div>
}
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input
type="email"
class="form-control"
[class.is-invalid]="isFieldInvalid('email')"
id="email"
formControlName="email"
autocomplete="email">
@if (isFieldInvalid('email')) {
<div class="invalid-feedback">{{ getFieldError('email') }}</div>
}
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<input
type="password"
class="form-control"
[class.is-invalid]="isFieldInvalid('password')"
id="password"
formControlName="password"
autocomplete="new-password">
@if (isFieldInvalid('password')) {
<div class="invalid-feedback">{{ getFieldError('password') }}</div>
}
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirmer le mot de passe</label>
<input
type="password"
class="form-control"
[class.is-invalid]="isFieldInvalid('confirmPassword')"
id="confirmPassword"
formControlName="confirmPassword"
autocomplete="new-password">
@if (isFieldInvalid('confirmPassword')) {
<div class="invalid-feedback">{{ getFieldError('confirmPassword') }}</div>
}
</div>
@if (registerForm.hasError('passwordMismatch') && (registerForm.dirty || registerForm.touched || isSubmitted)) {
<div class="alert alert-danger" role="alert">
Les mots de passe ne correspondent pas
</div>
}
<div class="form-check mb-3">
<input
type="checkbox"
class="form-check-input"
[class.is-invalid]="isFieldInvalid('termsAndConditions')"
id="iAgree"
formControlName="termsAndConditions">
<label class="form-check-label" for="iAgree">
J'accepte les <a href="#" target="_blank" rel="noopener">conditions générales d'utilisation</a>
</label>
@if (isFieldInvalid('termsAndConditions')) {
<div class="invalid-feedback d-block">{{ getFieldError('termsAndConditions') }}</div>
}
</div>
<button
type="submit"
class="btn btn-primary w-100"
[disabled]="isLoading || registerForm.invalid">
@if (isLoading) {
<span class="spinner-border spinner-border-sm me-2"></span>
Inscription…
} @else {
S'inscrire
}
</button>
</form>
<hr class="my-4">
<p class="text-center text-muted mb-0">
Vous avez déjà un compte ?
<a [routerLink]="'/login'">Se connecter</a>
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,154 @@
import { Component, inject, OnDestroy } from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth/auth-service';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-register',
standalone: true,
imports: [ReactiveFormsModule, RouterLink],
templateUrl: './register.html',
styleUrl: './register.css',
})
export class Register implements OnDestroy {
registerForm: FormGroup;
isSubmitted = false;
isLoading = false;
private readonly passwordPattern: RegExp = new RegExp(
'^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[\\p{P}\\p{S}]).{8,}$',
'u',
);
private readonly router: Router = inject(Router);
private readonly authService: AuthService = inject(AuthService);
private registerSubscription: Subscription | null = null;
constructor(private readonly formBuilder: FormBuilder) {
this.registerForm = this.formBuilder.group(
{
firstName: [
'',
[
Validators.required,
Validators.minLength(3),
Validators.maxLength(50),
Validators.pattern('^[a-zA-Z]+$'),
],
],
lastName: [
'',
[
Validators.required,
Validators.minLength(3),
Validators.maxLength(50),
Validators.pattern('^[a-zA-Z]+$'),
],
],
username: [
'',
[
Validators.required,
Validators.minLength(3),
Validators.maxLength(20),
Validators.pattern('^[a-zA-Z0-9_]+$'),
],
],
email: [
'',
[
Validators.required,
Validators.minLength(3),
Validators.maxLength(120),
Validators.email,
],
],
password: [
'',
[
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
Validators.pattern(this.passwordPattern),
],
],
confirmPassword: [
'',
[
Validators.required,
Validators.minLength(8),
Validators.maxLength(50),
Validators.pattern(this.passwordPattern),
],
],
termsAndConditions: [false, Validators.requiredTrue],
},
{ validators: this.passwordMatchValidator },
);
}
ngOnDestroy(): void {
this.registerSubscription?.unsubscribe();
}
private readonly passwordMatchValidator: ValidatorFn = (
group: AbstractControl,
): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
return password === confirmPassword ? null : { passwordMismatch: true };
};
onRegister() {
this.isSubmitted = true;
if (this.registerForm.valid) {
this.isLoading = true;
const registrationData = this.registerForm.value;
delete registrationData.confirmPassword;
this.registerSubscription = this.authService.register(registrationData).subscribe({
next: (response) => {
this.isLoading = false;
this.registerForm.reset();
this.isSubmitted = false;
alert('Registration successful!');
this.router.navigate(['/']).then();
},
error: (error) => {
console.error('Erreur HTTP:', error);
this.isLoading = false;
alert('An error occurred during registration. Please try again.');
},
});
}
}
isFieldInvalid(fieldName: string): boolean {
const field = this.registerForm.get(fieldName);
return Boolean(field && field.invalid && (field.dirty || field.touched || this.isSubmitted));
}
getFieldError(fieldName: string): string {
const field = this.registerForm.get(fieldName);
if (field && field.errors) {
if (field.errors['required']) return `Ce champ est obligatoire`;
if (field.errors['email']) return `Format d'email invalide`;
if (field.errors['minlength'])
return `Minimum ${field.errors['minlength'].requiredLength} caractères`;
if (field.errors['maxlength'])
return `Maximum ${field.errors['maxlength'].requiredLength} caractères`;
}
return '';
}
}

View File

View File

@@ -0,0 +1,27 @@
<!-- Home page header -->
<header class="bg-dark py-5">
<div class="container px-5">
<div class="row gx-5 justify-content-center">
<div class="col-lg-6">
<div class="text-center my-5">
@if (getUser(); as user) {
<h1 class="display-5 fw-bolder text-white mb-2">Bienvenue, {{ user.firstName }}!</h1>
<p class="lead text-white-50 mb-4">Choisissez une action à effectuer</p>
<div class="d-grid gap-3 d-sm-flex justify-content-sm-center">
<a href="/" class="btn btn-primary btn-lg px-4 me-sm-3">Voir la liste des moulinettes</a>
<a class="btn btn-outline-light btn-lg px-4" href="/" target="_blank">Autres outils</a>
</div>
} @else {
<h1 class="display-5 fw-bolder text-white mb-2">Bienvenue !</h1>
<p class="lead text-white-50 mb-4">Choisissez une action à effectuer</p>
<div class="d-grid gap-3 d-sm-flex justify-content-sm-center">
<a [routerLink]="'/login'" class="btn btn-primary btn-lg px-4 me-sm-3">Se connecter</a>
<a [routerLink]="'/register'" class="btn btn-outline-light btn-lg px-4">S'inscrire</a>
</div>
}
</div>
</div>
</div>
</div>
</header>

View File

@@ -0,0 +1,22 @@
import {Component, inject} from '@angular/core';
import {Router, RouterLink} from '@angular/router';
import { AuthService } from '../../services/auth/auth-service';
@Component({
selector: 'app-home',
standalone: true,
imports: [
RouterLink
],
templateUrl: './home.html',
styleUrl: './home.css'
})
export class Home {
protected readonly authService: AuthService = inject(AuthService);
protected readonly router: Router = inject(Router);
getUser() {
return this.authService.user();
}
}

View File

@@ -0,0 +1,31 @@
.min-vh-50 {
min-height: 50vh;
}
.profile-card {
max-width: 380px;
width: 100%;
border-radius: 18px;
border: none;
}
.profile-avatar {
width: 64px;
height: 64px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, .7);
color: white;
font-size: 48px;
}
.card-title {
font-size: 1.3rem;
}
.card-subtitle {
font-size: 1rem;
}

View File

@@ -0,0 +1,33 @@
@if (getUser(); as user) {
<div class="container d-flex justify-content-center align-items-center min-vh-50">
<div class="card profile-card shadow-sm">
<div class="card-body text-center">
<div class="profile-avatar mb-3">
<i class="bi bi-person-circle"></i>
</div>
<h5 class="card-title fw-bold mb-1">{{ user.firstName }} {{ user.lastName }}</h5>
@if (user.role == "Administrator") {
<p class="card-subtitle text-muted mb-3">{{ user.username }} ({{ user.role }})</p>
} @else {
<p class="card-subtitle text-muted mb-3">{{ user.username }}</p>
}
<div class="d-flex align-items-center justify-content-center gap-2 text-secondary">
<i class="bi bi-envelope"></i>
<span>{{ user.email }}</span>
</div>
<div class="d-flex justify-content-center gap-2 mt-4">
<button class="btn btn-primary">
<i class="bi bi-pencil"></i> Modifier le profil
</button>
<button class="btn btn-danger" (click)="logout()">
<i class="bi bi-box-arrow-right"></i> Se déconnecter
</button>
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,32 @@
import {Component, inject} from '@angular/core';
import {Router} from '@angular/router';
import { AuthService } from '../../services/auth/auth-service';
import { User } from '../../interfaces/user/user';
@Component({
selector: 'app-profile',
standalone: true,
imports: [],
templateUrl: './profile.html',
styleUrl: './profile.css'
})
export class Profile {
private readonly authService: AuthService = inject(AuthService);
private readonly router: Router = inject(Router);
logout() {
this.authService.logout().subscribe({
next: () => {
this.router.navigate(['/login'], {queryParams: {redirect: '/profile'}}).then();
},
error: (err) => {
console.error('Logout failed:', err);
}
});
}
getUser(): User | null {
return this.authService.user();
}
}

View File

@@ -0,0 +1,96 @@
import { inject, Injectable, signal } from '@angular/core';
import { environment } from '../../../environments/environment';
import { HttpClient } from '@angular/common/http';
import { catchError, map, of, switchMap, tap } from 'rxjs';
import { User } from '../../interfaces/user/user';
import { Credentials } from '../../interfaces/auth/credentials';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly http: HttpClient = inject(HttpClient);
private readonly BASE_URL = `${environment.apiUrl}/auth`;
readonly user = signal<User | null>(null);
private readonly accessToken = signal<string | null>(null);
readonly isInitialized = signal(false);
constructor() {
this.refresh()
.pipe(switchMap(() => this.me()))
.subscribe({
complete: () => this.isInitialized.set(true),
error: () => this.isInitialized.set(true),
});
}
register(user: User) {
return this.http.post(this.BASE_URL + '/register', user, { withCredentials: true });
}
login(credentials: Credentials) {
return this.http
.post<any>(this.BASE_URL + '/login', credentials, { withCredentials: true })
.pipe(switchMap(() => this.me()));
}
logout() {
return this.http.get(this.BASE_URL + '/logout', { withCredentials: true }).pipe(
tap(() => {
this.user.set(null);
this.accessToken.set(null);
}),
map(() => null),
);
}
me() {
return this.http
.get<User>(this.BASE_URL + '/me', {
withCredentials: true,
})
.pipe(
tap((u) => this.user.set(u)),
catchError(() => {
this.user.set(null);
this.accessToken.set(null);
return of(null);
}),
);
}
refresh() {
return this.http
.post<any>(
this.BASE_URL + '/refresh',
{},
{
withCredentials: true,
},
)
.pipe(
tap((res) => {
if (res?.accessToken) {
this.accessToken.set(res.accessToken);
}
}),
catchError(() => of(null)),
);
}
getAccessToken(): string | null {
return this.accessToken();
}
isLoggedIn(): boolean {
return this.user() !== null;
}
hasRole(role: string): boolean {
const user = this.user();
if (!user) return false;
const roles = Array.isArray((user as any).roles) ? (user as any).roles : [(user as any).role];
return roles?.includes(role) ?? false;
}
}

View File

@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiUrl: 'https://portail.bs.local/api'
};

View File

@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:8080/api'
};

27
webclient/src/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Portail BS</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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>
<app-root></app-root>
</body>
</html>

6
webclient/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1,38 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
)
);
}
body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}

1
webclient/src/styles.css Normal file
View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */