11

Lab 11 - Angular Frontend (Part 1)

Partea 2 din 4

Actualizat 2026-05-11Sursă pe GitHub ↗Cod start

Parte 2 — Modele și servicii

Frontend-ul Angular are aceeași stratificare ca backend-ul: modele (descriu datele), servicii (operații peste API), componente (UI). Aici scriem stratul de date și logică, separat de UI.

Modele TypeScript

Modelele sunt interfețe — descriu forma JSON-ului primit de la API. TypeScript nu le rulează la runtime (sunt șterse la build), dar la compile time verifică că nu accesăm câmpuri inexistente.

src/app/shared/models/article.ts:

export interface Article {
  id: number;
  title: string;
  content: string;
  publishedAt: string;       // ISO date string din JSON
  categoryId: number;
  categoryName: string;
  authorId: string;
  authorName: string;
  tags?: string[];           // optional - poate lipsi
}

export interface Category {
  id: number;
  name: string;
}

export interface CurrentUser {
  id: string;
  name: string;
  email: string;
  roles: string[];
}
Detaliu De ce
publishedAt: string (nu Date) JSON nu are tipul Date — vine ca string ISO 8601. Conversia o faceți în template cu | date pipe sau în service.
tags?: string[] ? = opțional. Dacă API-ul nu populează tags, article.tags e undefined — iar TS îl semnalează.
roles: string[] JWT-ul nostru pune rolurile ca un array de claims ClaimTypes.Role. Decodarea le adună într-un string[].

Notă: Modelele frontend nu sunt o copie 1-la-1 a entităților EF — sunt o copie a DTO-urilor API (ArticleDto, etc). Backend-ul deja face Entity → DTO în Mappings/. Frontend-ul trebuie să se uite doar la DTO, nu la entitate.

AuthService — login, register, state

AuthService e responsabil de tot ce ține de autentificare: apel API pentru login/register, salvarea token-ului JWT, expunerea utilizatorului curent ca stream observabil.

src/app/core/services/auth.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, tap } from 'rxjs';
import { environment } from '../../../environments/environment';
import { CurrentUser } from '../../shared/models/article';

interface AuthResponse {
  token: string;
  expiresIn: number;
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private apiUrl = `${environment.apiUrl}/api/auth`;
  private currentUserSubject = new BehaviorSubject<CurrentUser | null>(null);
  public currentUser$ = this.currentUserSubject.asObservable();

  constructor(private http: HttpClient) {
    this.loadUserFromToken();
  }

  login(email: string, password: string): Observable<AuthResponse> {
    return this.http.post<AuthResponse>(`${this.apiUrl}/login`, { email, password })
      .pipe(tap(res => this.handleAuthResponse(res.token)));
  }

  register(email: string, fullName: string, password: string): Observable<AuthResponse> {
    return this.http.post<AuthResponse>(`${this.apiUrl}/register`, { email, fullName, password })
      .pipe(tap(res => this.handleAuthResponse(res.token)));
  }

  logout(): void {
    localStorage.removeItem('token');
    this.currentUserSubject.next(null);
  }

  isAuthenticated(): boolean {
    return localStorage.getItem('token') !== null;
  }

  getToken(): string | null {
    return localStorage.getItem('token');
  }

  private handleAuthResponse(token: string): void {
    localStorage.setItem('token', token);
    this.currentUserSubject.next(this.decodeUser(token));
  }

  private loadUserFromToken(): void {
    const token = localStorage.getItem('token');
    if (token) {
      this.currentUserSubject.next(this.decodeUser(token));
    }
  }

  private decodeUser(token: string): CurrentUser {
    const payload = this.decodeJwtPayload(token);
    const roleClaim = payload['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'];
    const roles = Array.isArray(roleClaim) ? roleClaim : roleClaim ? [roleClaim] : [];
    return {
      id: payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'],
      name: payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'],
      email: payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
      roles
    };
  }

  private decodeJwtPayload(token: string): any {
    const base64Url = token.split('.')[1];
    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
    const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
    return JSON.parse(atob(padded));
  }
}
Sequence diagram: utilizatorul completeaza form si trimite la LoginComponent; LoginComponent cheama AuthService.login; AuthService face POST /api/auth/login; backend valideaza parola si genereaza JWT; AuthService primeste token, il pune in localStorage, decodeaza claims-urile in CurrentUser si emite cu BehaviorSubject.next; HeaderComponent (abonat via async pipe) re-randeaza cu numele user-ului; LoginComponent navigheaza la /articles. Note explicative pentru BehaviorSubject (retine ultima valoare) si localStorage (supravietuieste refresh).

Concepte cheie

  • @Injectable({ providedIn: 'root' }) — Angular îl creează o singură dată pe toată aplicația (singleton). Nu trebuie listat în providers la modul.
  • BehaviorSubject — un Observable care reține ultima valoare emisă. Orice componentă care se abonează primește instant valoarea curentă (utilizatorul logat sau null). E echivalentul reactiv al unei proprietăți cu getter, fără polling.
  • localStorage — stocăm JWT-ul în browser, supraviețuiește refresh-urilor de pagină. La pornire, loadUserFromToken() reconstruiește state-ul.
  • tap() — un operator RxJS care ne lasă să facem side effects (salvare, log) fără să modificăm valoarea ce trece prin stream.

De ce URL-urile alea ciudate la decode?

JWT-urile generate de ASP.NET Core Identity au claims cu schema XML (ClaimTypes.NameIdentifier se serializează ca http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier). Sunt verbose dar predictibile — exact strings constante.

Backend-ul nostru pune trei claim-uri standard + rolurile:

// In AuthApiController.GenerateJwtAsync
var claims = new List<Claim>
{
    new(ClaimTypes.NameIdentifier, user.Id),       // -> .../nameidentifier
    new(ClaimTypes.Name, user.UserName!),          // -> .../name
    new(ClaimTypes.Email, user.Email!)             // -> .../emailaddress
};
foreach (var role in roles)
    claims.Add(new Claim(ClaimTypes.Role, role));  // -> .../role (poate fi array daca >1)

Alternativă: la backend, mapați-le explicit la nume scurte (sub, name, email, role) cu JwtRegisteredClaimNames — mai standard JWT, dar atunci trebuie ajustat și middleware-ul JWT din Program.cs ca să le citească corect. Pentru lab rămânem la default-ul ASP.NET Core.

decodeJwtPayload — de ce nu doar atob(token.split('.')[1])

Token-ul JWT codifică payload-ul în base64url (variantă URL-safe a base64). Diferențele:

Standard Caractere Padding
base64 (folosit de atob) A-Z a-z 0-9 + / = = la sfârșit dacă lungimea nu e multiplu de 4
base64url (folosit JWT) A-Z a-z 0-9 - _ fără padding

atob direct pe base64url fail-uiește când payload-ul conține - sau _ sau când lungimea nu e divizibilă cu 4. Patch-urile replace + padding îl normalizează.

ArticleService (read-only)

src/app/core/services/article.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Article } from '../../shared/models/article';
import { environment } from '../../../environments/environment';

@Injectable({ providedIn: 'root' })
export class ArticleService {
  private apiUrl = `${environment.apiUrl}/api/articles`;

  constructor(private http: HttpClient) {}

  getAll(): Observable<Article[]> {
    return this.http.get<Article[]>(this.apiUrl);
  }

  getById(id: number): Observable<Article> {
    return this.http.get<Article>(`${this.apiUrl}/${id}`);
  }

  // create(), update(), delete() ajung in Lab 12
}

Despre Observable vs Promise

HttpClient returnează Observable, nu Promise. Observabilele:

  • pot emite mai multe valori (relevant la WebSocket / SSE)
  • sunt lazy — request-ul HTTP se trimite doar la .subscribe(), nu la apel
  • sunt anulabilesubscription.unsubscribe() întrerupe cererea
  • se compun cu operatori (map, filter, switchMap, …)

Echivalentul mental .NET: Task<T> e Promise (rezultat singular, nu se compune ușor). IAsyncEnumerable<T> e cel mai aproape de Observable din C#.

Pattern de consum în componentă (state în signals, vezi Partea 3 pentru detalii):

articles = signal<Article[]>([]);
error = signal<string | null>(null);

this.articleService.getAll().subscribe({
  next: a => this.articles.set(a),
  error: () => this.error.set('Eroare la incarcare')
});

Notă: subscribe fără să mai chemăm unsubscribe poate produce memory leaks — Angular nu le curăță automat. În lab, pentru request-uri HTTP scurte la inițializare, e acceptabil. Pentru abonări de lungă durată folosiți takeUntilDestroyed() (Angular 16+) sau async pipe.

authInterceptor — Authorization header automat

Un HTTP Interceptor prinde fiecare request înainte să plece și fiecare response înainte să ajungă la apelant. Îl folosim ca să atașăm Authorization: Bearer <token> global, fără să-l scriem în fiecare service.

În Angular 16+ interceptorii sunt funcții pure (HttpInterceptorFn), nu clase. Folosesc inject() în loc de constructor și se înregistrează cu provideHttpClient(withInterceptors([...])).

src/app/core/services/auth-interceptor.ts:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).getToken();

  if (token) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` }
    });
  }

  return next(req);
};
Pipeline orizontal: ArticleListComponent cheama ArticleService.getAll() care apeleaza HttpClient; cererea trece prin authInterceptor (evidentiat galben) care cloneaza requestul adaugand Authorization: Bearer token; pleaca prin HttpBackend (XHR/fetch) catre backend-ul /api/articles unde header-ul Authorization apare deja atasat; raspunsul vine inapoi pe acelasi traseu si Observable-ul emite valoarea catre componenta.

Detalii

  • HttpRequest e immutablereq.clone({ setHeaders: ... }) produce o copie modificată. Așa Angular evită bug-uri de retry care ar muta același request.
  • Interceptor funcțional: e o funcție (req, next) => Observable<HttpEvent>. inject(AuthService) funcționează în interiorul ei pentru că rulează într-un context Angular activ (provided prin provideHttpClient în app.config.ts — vezi Partea 3).
  • Pentru request-urile fără token (login, register, GET public) interceptor-ul nu adaugă nimic — backend-ul ignoră Bearer absent pe rute publice.
  • Vechiul API (clase cu implements HttpInterceptor + HTTP_INTERCEPTORS multi-token) încă funcționează, dar nu mai e default-ul. CLI 17+ generează interceptors funcționali.

authGuard — protecție de rute

Un Guard verifică o condiție înainte de a permite navigarea către o rută. CanActivateFn rulează la matching — rezultă fie acces permis (true), fie blocare cu redirect.

Ca și interceptorii, guards-urile moderne sunt funcții, nu clase, și folosesc inject() direct.

src/app/core/guards/auth-guard.ts:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth';

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) {
    return true;
  }

  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
  return false;
};

Folosire (în Lab 12)

// In app.routes.ts (Lab 12, pentru rute protejate)
{ path: 'articles/new', component: ArticleCreate, canActivate: [authGuard] }

În Lab 11 toate rutele sunt fie publice (lista, detaliu), fie de auth (login, register) — nu folosim guard-ul efectiv. Îl scriem acum ca infrastructură pregătită pentru Lab 12.

Notă: Verificarea autentificării se face doar pe prezența token-ului, nu pe valabilitatea lui. Un JWT expirat trece de guard, dar primul request către API întoarce 401. Pentru protecție mai strictă: parsați exp din JWT și verificați-l. În producție, folosiți și un interceptor de erori care la 401 face logout automat.