Add scouthub-home-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:40:51 +02:00
parent 566754f825
commit c34bca869f
49 changed files with 10370 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
createInterceptorCondition,
IncludeBearerTokenCondition,
includeBearerTokenInterceptor,
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG
} from 'keycloak-angular';
import { routes } from './app.routes';
import { provideKeycloakAngular } from './core/auth/keycloak.provider';
import { environment } from '../environments/environment';
const escapedOrgServiceBaseUrl = environment.orgServiceApiBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const orgServiceBearerCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
urlPattern: new RegExp(`^${escapedOrgServiceBaseUrl}(/.*)?$`, 'i'),
bearerPrefix: 'Bearer'
});
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideKeycloakAngular(),
{
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [orgServiceBearerCondition]
},
provideHttpClient(withInterceptors([includeBearerTokenInterceptor]))
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+20
View File
@@ -0,0 +1,20 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home').then((m) => m.Home),
canActivate: [authGuard]
},
{
path: 'crea-gruppo',
loadChildren: () => import('./crea-gruppo/crea-gruppo.routes').then((m) => m.CREA_GRUPPO_ROUTES)
},
{
// Pubblica: un invito deve poter essere visualizzato anche da chi non ha ancora un account.
path: 'inviti',
loadChildren: () => import('./inviti/inviti.routes').then((m) => m.INVITI_ROUTES)
}
];
+16
View File
@@ -0,0 +1,16 @@
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();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {}
@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, provideRouter, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import Keycloak from 'keycloak-js';
import { authGuard } from './auth.guard';
const route = {} as ActivatedRouteSnapshot;
const state = { url: '/inviti' } as RouterStateSnapshot;
function setup(keycloakMock: Partial<Keycloak>): void {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: Keycloak, useValue: keycloakMock }]
});
}
describe('authGuard', () => {
it('avvia il login se l\'utente non è autenticato e non fa proseguire la navigazione', async () => {
const login = vi.fn().mockResolvedValue(undefined);
setup({ authenticated: false, login });
const result = await TestBed.runInInjectionContext(() => authGuard(route, state));
expect(login).toHaveBeenCalledWith({ redirectUri: window.location.origin + '/inviti' });
expect(result).toBe(false);
});
it('reindirizza a /crea-gruppo se l\'utente è autenticato ma senza organization', async () => {
setup({ authenticated: true, tokenParsed: {} });
const result = await TestBed.runInInjectionContext(() => authGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/crea-gruppo').toString());
});
it('lascia proseguire la navigazione se l\'utente è autenticato e ha una organization', async () => {
setup({
authenticated: true,
tokenParsed: {
organization: {
'gruppo-scout-milano-1': { id: 'org-123' }
}
}
});
const result = await TestBed.runInInjectionContext(() => authGuard(route, state));
expect(result).toBe(true);
});
});
@@ -0,0 +1,24 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { AuthGuardData, createAuthGuard } from 'keycloak-angular';
import { extractOrganizationFromToken } from '../organization-context.service';
export async function isAccessAllowed(
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
authData: AuthGuardData
): Promise<boolean | UrlTree> {
const router = inject(Router);
const { authenticated, keycloak } = authData;
if (!authenticated) {
await keycloak.login({ redirectUri: window.location.origin + state.url });
return false;
}
const hasOrganization = extractOrganizationFromToken(keycloak.tokenParsed) !== null;
return hasOrganization ? true : router.parseUrl('/crea-gruppo');
}
export const authGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAccessAllowed);
@@ -0,0 +1,26 @@
import { EnvironmentProviders } from '@angular/core';
import { provideKeycloak, withAutoRefreshToken, AutoRefreshTokenService, UserActivityService } from 'keycloak-angular';
import { environment } from '../../../environments/environment';
export function provideKeycloakAngular(): EnvironmentProviders {
return provideKeycloak({
config: {
url: environment.keycloakBaseUrl,
realm: environment.keycloakRealm,
clientId: environment.keycloakClientId
},
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
redirectUri: window.location.origin + '/'
},
features: [
withAutoRefreshToken({
onInactivityTimeout: 'logout',
sessionTimeout: 300000
})
],
providers: [AutoRefreshTokenService, UserActivityService]
});
}
@@ -0,0 +1,81 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL, KeycloakEvent, KeycloakEventType } from 'keycloak-angular';
import { OrganizationContextService } from './organization-context.service';
function setup(keycloakMock: Partial<Keycloak>): OrganizationContextService {
TestBed.configureTestingModule({
providers: [
{ provide: Keycloak, useValue: keycloakMock },
{
provide: KEYCLOAK_EVENT_SIGNAL,
useValue: signal<KeycloakEvent>({ type: KeycloakEventType.Ready, args: true })
}
]
});
const service = TestBed.inject(OrganizationContextService);
// toObservable propaga il valore del signal tramite un effect: va sincronizzato
// esplicitamente in test, non essendoci un ciclo di change detection reale.
TestBed.tick();
return service;
}
describe('OrganizationContextService', () => {
it('espone id e nome della organization quando il claim "organization" è presente nel token', async () => {
const service = setup({
authenticated: true,
tokenParsed: {
organization: {
'gruppo-scout-milano-1': { id: 'org-123' }
}
}
});
expect(await firstValueFrom(service.hasOrganization)).toBe(true);
expect(await firstValueFrom(service.currentOrganizationId)).toBe('org-123');
expect(await firstValueFrom(service.currentOrganizationName)).toBe('gruppo-scout-milano-1');
});
it('usa l\'alias come id/nome quando il claim non porta un "id" esplicito', async () => {
const service = setup({
authenticated: true,
tokenParsed: {
organization: {
'gruppo-scout-milano-1': {}
}
}
});
expect(await firstValueFrom(service.currentOrganizationId)).toBe('gruppo-scout-milano-1');
expect(await firstValueFrom(service.currentOrganizationName)).toBe('gruppo-scout-milano-1');
});
it('non espone alcuna organization quando il claim è assente dal token', async () => {
const service = setup({
authenticated: true,
tokenParsed: {}
});
expect(await firstValueFrom(service.hasOrganization)).toBe(false);
expect(await firstValueFrom(service.currentOrganizationId)).toBeNull();
expect(await firstValueFrom(service.currentOrganizationName)).toBeNull();
});
it('non espone alcuna organization se l\'utente non è autenticato, anche con claim presente', async () => {
const service = setup({
authenticated: false,
tokenParsed: {
organization: {
'gruppo-scout-milano-1': { id: 'org-123' }
}
}
});
expect(await firstValueFrom(service.hasOrganization)).toBe(false);
});
});
@@ -0,0 +1,58 @@
import { Injectable, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import Keycloak, { KeycloakTokenParsed } from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
export interface OrganizationInfo {
id: string;
name: string;
}
interface OrganizationClaimEntry {
id?: string;
name?: string;
}
/**
* Il claim "organization" (client scope built-in di Keycloak Organizations) ha la forma
* `{ [aliasOrganizzazione]: { id?, name? } }`: la SPA riceve un solo membership perché la
* scelta tra più Organization è già stata fatta dall'utente nella schermata nativa di Keycloak.
*/
export function extractOrganizationFromToken(tokenParsed: KeycloakTokenParsed | undefined): OrganizationInfo | null {
const claim = tokenParsed?.['organization'] as Record<string, OrganizationClaimEntry> | undefined;
if (!claim) {
return null;
}
const [alias, entry] = Object.entries(claim)[0] ?? [];
if (!alias) {
return null;
}
return {
id: entry?.id ?? alias,
name: entry?.name ?? alias
};
}
@Injectable({ providedIn: 'root' })
export class OrganizationContextService {
private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = toObservable(inject(KEYCLOAK_EVENT_SIGNAL));
private readonly organization$: Observable<OrganizationInfo | null> = this.keycloakEvent.pipe(
map(() => (this.keycloak.authenticated ? extractOrganizationFromToken(this.keycloak.tokenParsed) : null))
);
readonly hasOrganization: Observable<boolean> = this.organization$.pipe(map((organization) => organization !== null));
readonly currentOrganizationId: Observable<string | null> = this.organization$.pipe(
map((organization) => organization?.id ?? null)
);
readonly currentOrganizationName: Observable<string | null> = this.organization$.pipe(
map((organization) => organization?.name ?? null)
);
}
@@ -0,0 +1,34 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, provideRouter, RouterStateSnapshot } from '@angular/router';
import Keycloak from 'keycloak-js';
import { requireAuthGuard } from './require-auth.guard';
const route = {} as ActivatedRouteSnapshot;
const state = { url: '/crea-gruppo' } as RouterStateSnapshot;
function setup(keycloakMock: Partial<Keycloak>): void {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: Keycloak, useValue: keycloakMock }]
});
}
describe('requireAuthGuard', () => {
it('avvia il login se l\'utente non è autenticato e non fa proseguire la navigazione', async () => {
const login = vi.fn().mockResolvedValue(undefined);
setup({ authenticated: false, login });
const result = await TestBed.runInInjectionContext(() => requireAuthGuard(route, state));
expect(login).toHaveBeenCalledWith({ redirectUri: window.location.origin + '/crea-gruppo' });
expect(result).toBe(false);
});
it('lascia proseguire la navigazione se l\'utente è autenticato, senza controllare l\'organization', async () => {
setup({ authenticated: true, tokenParsed: {} });
const result = await TestBed.runInInjectionContext(() => requireAuthGuard(route, state));
expect(result).toBe(true);
});
});
@@ -0,0 +1,19 @@
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router';
import { AuthGuardData, createAuthGuard } from 'keycloak-angular';
export async function isAuthenticated(
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
authData: AuthGuardData
): Promise<boolean | UrlTree> {
const { authenticated, keycloak } = authData;
if (!authenticated) {
await keycloak.login({ redirectUri: window.location.origin + state.url });
return false;
}
return true;
}
export const requireAuthGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAuthenticated);
@@ -0,0 +1,20 @@
.crea-gruppo {
max-width: 480px;
margin: 2rem auto;
padding: 0 1rem;
}
.crea-gruppo__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.crea-gruppo__field {
width: 100%;
}
.crea-gruppo__error {
color: var(--mat-sys-error, #b3261e);
margin: 0;
}
@@ -0,0 +1,26 @@
<section class="crea-gruppo">
<h1>Crea un nuovo gruppo scout</h1>
<p>Il tuo account non è ancora collegato a nessun gruppo scout: creane uno per continuare.</p>
<form class="crea-gruppo__form" (submit)="$event.preventDefault(); submit()" novalidate>
<mat-form-field appearance="outline" class="crea-gruppo__field">
<mat-label>Nome del gruppo scout</mat-label>
<input matInput [formControl]="nome" placeholder="Es. Agesci Milano 1" />
@if (nome.hasError('required')) {
<mat-error>Il nome del gruppo è obbligatorio.</mat-error>
} @else if (nome.hasError('minlength')) {
<mat-error>Il nome deve avere almeno 3 caratteri.</mat-error>
} @else if (nome.hasError('maxlength')) {
<mat-error>Il nome non può superare i 100 caratteri.</mat-error>
}
</mat-form-field>
@if (errorMessage(); as message) {
<p class="crea-gruppo__error" role="alert">{{ message }}</p>
}
<button mat-flat-button color="primary" type="submit" [disabled]="submitting()">
{{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
</button>
</form>
</section>
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const CREA_GRUPPO_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./crea-gruppo').then((m) => m.CreaGruppo),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,129 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { CreaGruppo } from './crea-gruppo';
import { GruppiApiService } from './gruppi-api.service';
describe('CreaGruppo', () => {
let component: CreaGruppo;
let fixture: ComponentFixture<CreaGruppo>;
let gruppiApi: { creaGruppo: ReturnType<typeof vi.fn> };
let keycloak: { updateToken: ReturnType<typeof vi.fn> };
let router: { navigateByUrl: ReturnType<typeof vi.fn> };
beforeEach(async () => {
gruppiApi = { creaGruppo: vi.fn() };
keycloak = { updateToken: vi.fn().mockResolvedValue(true) };
router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
await TestBed.configureTestingModule({
imports: [CreaGruppo],
providers: [
{ provide: GruppiApiService, useValue: gruppiApi },
{ provide: Keycloak, useValue: keycloak },
{ provide: Router, useValue: router }
]
}).compileComponents();
fixture = TestBed.createComponent(CreaGruppo);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('si crea correttamente', () => {
expect(component).toBeTruthy();
});
describe('validazione del campo nome', () => {
it('è invalido se vuoto (required)', () => {
component.nome.setValue('');
expect(component.nome.hasError('required')).toBe(true);
});
it('è invalido con meno di 3 caratteri', () => {
component.nome.setValue('Ab');
expect(component.nome.hasError('minlength')).toBe(true);
});
it('è valido con esattamente 3 caratteri', () => {
component.nome.setValue('Ab1');
expect(component.nome.valid).toBe(true);
});
it('è invalido oltre i 100 caratteri', () => {
component.nome.setValue('a'.repeat(101));
expect(component.nome.hasError('maxlength')).toBe(true);
});
it('è valido con esattamente 100 caratteri', () => {
component.nome.setValue('a'.repeat(100));
expect(component.nome.valid).toBe(true);
});
it('è valido con un nome tipico', () => {
component.nome.setValue('Agesci Milano 1');
expect(component.nome.valid).toBe(true);
});
it('non chiama l\'API e marca il controllo come touched se il nome non è valido', async () => {
component.nome.setValue('');
await component.submit();
expect(gruppiApi.creaGruppo).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
});
});
describe('gestione errore 409 (nome già esistente)', () => {
it('mostra il messaggio del backend e permette di correggere il nome senza ricaricare la pagina', async () => {
component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { message: 'Esiste già un gruppo scout con nome "Agesci Milano 1"' }
})
)
);
await component.submit();
expect(component.errorMessage()).toBe('Esiste già un gruppo scout con nome "Agesci Milano 1"');
expect(component.submitting()).toBe(false);
expect(router.navigateByUrl).not.toHaveBeenCalled();
// il form resta utilizzabile: si può correggere il nome e ritentare senza reload
component.nome.setValue('Agesci Milano 2');
expect(component.nome.valid).toBe(true);
});
it('mostra un messaggio generico per errori diversi da 409', async () => {
component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
await component.submit();
expect(component.errorMessage()).toBe('Si è verificato un errore imprevisto. Riprova.');
});
});
describe('redirect dopo il successo', () => {
it('aggiorna forzatamente il token e reindirizza alla home dopo la creazione del gruppo', async () => {
component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue(of({ orgId: 'org-1', gruppiCreati: ['Capi'] }));
await component.submit();
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith('Agesci Milano 1');
expect(keycloak.updateToken).toHaveBeenCalledWith(-1);
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
expect(component.submitting()).toBe(false);
expect(component.errorMessage()).toBeNull();
});
});
});
@@ -0,0 +1,67 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { GruppiApiService } from './gruppi-api.service';
@Component({
selector: 'app-crea-gruppo',
imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatProgressSpinnerModule],
templateUrl: './crea-gruppo.html',
styleUrl: './crea-gruppo.css'
})
export class CreaGruppo {
private readonly gruppiApi = inject(GruppiApiService);
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
readonly nome = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)]
});
readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null);
async submit(): Promise<void> {
if (this.submitting()) {
return;
}
if (this.nome.invalid) {
this.nome.markAsTouched();
return;
}
this.errorMessage.set(null);
this.submitting.set(true);
try {
await firstValueFrom(this.gruppiApi.creaGruppo(this.nome.value.trim()));
// Il gruppo è appena stato creato su Keycloak: il token corrente non contiene ancora
// il claim "organization" aggiornato, va quindi forzato un refresh prima di navigare.
await this.keycloak.updateToken(-1).catch(() => undefined);
this.submitting.set(false);
await this.router.navigateByUrl('/');
} catch (error) {
this.submitting.set(false);
this.handleError(error as HttpErrorResponse);
}
}
private handleError(error: HttpErrorResponse): void {
if (error.status === 409) {
this.errorMessage.set(error.error?.message ?? 'Esiste già un gruppo scout con questo nome.');
return;
}
this.errorMessage.set('Si è verificato un errore imprevisto. Riprova.');
}
}
@@ -0,0 +1,19 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface CreaGruppoResponse {
orgId: string;
gruppiCreati: string[];
}
@Injectable({ providedIn: 'root' })
export class GruppiApiService {
private readonly http = inject(HttpClient);
creaGruppo(nome: string): Observable<CreaGruppoResponse> {
return this.http.post<CreaGruppoResponse>(`${environment.orgServiceApiBaseUrl}/gruppi`, { nome });
}
}
+39
View File
@@ -0,0 +1,39 @@
.dashboard {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 2rem;
max-width: 640px;
margin: 0 auto;
}
.servizi {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.servizio-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s ease;
}
.servizio-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.servizio-card__titolo {
font-weight: 600;
}
.servizio-card__descrizione {
font-size: 0.875rem;
opacity: 0.75;
}
+13
View File
@@ -0,0 +1,13 @@
<section class="dashboard">
<h1>{{ organizationName() ?? 'Il tuo gruppo scout' }}</h1>
<section class="servizi">
<h2>Servizi</h2>
<a class="servizio-card" [href]="attivitaAppBaseUrl">
<span class="servizio-card__titolo">Attività</span>
<span class="servizio-card__descrizione">Catalogo e ricerca delle attività scout</span>
</a>
</section>
<button mat-stroked-button type="button" (click)="logout()">Esci</button>
</section>
@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import Keycloak from 'keycloak-js';
import { Observable, of } from 'rxjs';
import { environment } from '../../environments/environment';
import { OrganizationContextService } from '../core/organization-context.service';
import { Home } from './home';
describe('Home', () => {
let component: Home;
let fixture: ComponentFixture<Home>;
let organizationContext: { currentOrganizationName: Observable<string | null> };
let keycloak: { logout: ReturnType<typeof vi.fn> };
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Home],
providers: [
{ provide: OrganizationContextService, useValue: organizationContext },
{ provide: Keycloak, useValue: keycloak }
]
}).compileComponents();
fixture = TestBed.createComponent(Home);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(() => {
organizationContext = { currentOrganizationName: of('Agesci Milano 1') };
keycloak = { logout: vi.fn() };
});
it('si crea correttamente', async () => {
await setup();
expect(component).toBeTruthy();
});
it('mostra il nome del gruppo scout corrente', async () => {
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Agesci Milano 1');
});
it('mostra il link verso l\'app Attività puntando a attivitaAppBaseUrl', async () => {
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('a.servizio-card') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe(environment.attivitaAppBaseUrl);
});
it('invoca il logout di Keycloak al click sul pulsante "Esci"', async () => {
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('button') as HTMLButtonElement;
button.click();
expect(keycloak.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin + '/' });
});
});
+25
View File
@@ -0,0 +1,25 @@
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import Keycloak from 'keycloak-js';
import { environment } from '../../environments/environment';
import { OrganizationContextService } from '../core/organization-context.service';
@Component({
selector: 'app-home',
imports: [MatButtonModule],
templateUrl: './home.html',
styleUrl: './home.css'
})
export class Home {
private readonly organizationContext = inject(OrganizationContextService);
private readonly keycloak = inject(Keycloak);
readonly organizationName = toSignal(this.organizationContext.currentOrganizationName, { initialValue: null });
readonly attivitaAppBaseUrl = environment.attivitaAppBaseUrl;
logout(): void {
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
}
}
@@ -0,0 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface InvitoPubblico {
email: string;
nomeGruppo: string;
ruolo: string;
valido: boolean;
}
export interface AccettaInvitoResult {
organizationId: string;
ruolo: string;
}
@Injectable({ providedIn: 'root' })
export class InvitiApiService {
private readonly http = inject(HttpClient);
getInvito(token: string): Observable<InvitoPubblico> {
return this.http.get<InvitoPubblico>(`${environment.orgServiceApiBaseUrl}/inviti/${token}`);
}
accettaInvito(token: string): Observable<AccettaInvitoResult> {
return this.http.post<AccettaInvitoResult>(`${environment.orgServiceApiBaseUrl}/inviti/${token}/accetta`, {});
}
}
@@ -0,0 +1,9 @@
.inviti {
max-width: 480px;
margin: 2rem auto;
padding: 0 1rem;
}
.inviti__error {
color: var(--mat-sys-error, #b3261e);
}
@@ -0,0 +1,24 @@
<section class="inviti">
@if (loading()) {
<p>Caricamento invito…</p>
} @else if (loadError(); as message) {
<p class="inviti__error" role="alert">{{ message }}</p>
} @else if (invito(); as invitoData) {
<h1>Invito al gruppo scout {{ invitoData.nomeGruppo }}</h1>
<p>Ruolo offerto: <strong>{{ invitoData.ruolo }}</strong></p>
@if (!invitoData.valido) {
<p class="inviti__error" role="alert">
Questo invito non è più valido: potrebbe essere scaduto o già stato accettato.
</p>
} @else {
@if (acceptError(); as errMessage) {
<p class="inviti__error" role="alert">{{ errMessage }}</p>
}
<button mat-flat-button color="primary" (click)="accetta()" [disabled]="accepting()">
{{ accepting() ? 'Accettazione in corso…' : 'Accetta invito' }}
</button>
}
}
</section>
@@ -0,0 +1,8 @@
import { Routes } from '@angular/router';
export const INVITI_ROUTES: Routes = [
{
path: ':token',
loadComponent: () => import('./inviti').then((m) => m.Inviti)
}
];
@@ -0,0 +1,131 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { Inviti } from './inviti';
import { InvitiApiService, InvitoPubblico } from './inviti-api.service';
describe('Inviti', () => {
let component: Inviti;
let fixture: ComponentFixture<Inviti>;
let invitiApi: { getInvito: ReturnType<typeof vi.fn>; accettaInvito: ReturnType<typeof vi.fn> };
let keycloak: { authenticated: boolean; login: ReturnType<typeof vi.fn>; updateToken: ReturnType<typeof vi.fn> };
let router: { navigateByUrl: ReturnType<typeof vi.fn> };
const invitoValido: InvitoPubblico = {
email: 'mario.rossi@example.com',
nomeGruppo: 'Agesci Milano 1',
ruolo: 'Capi',
valido: true
};
// Il componente viene creato senza far girare la change detection di Angular: ngOnInit
// viene invocato e atteso manualmente una sola volta, per uno stato deterministico
// (evita che l'auto-init di Angular alla prima detectChanges() esegua un secondo
// caricamento asincrono in parallelo a quello già atteso qui).
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Inviti],
providers: [
{ provide: InvitiApiService, useValue: invitiApi },
{ provide: Keycloak, useValue: keycloak },
{ provide: Router, useValue: router },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ token: 'token-abc' }) } }
}
]
}).compileComponents();
fixture = TestBed.createComponent(Inviti);
component = fixture.componentInstance;
await component.ngOnInit();
}
beforeEach(() => {
invitiApi = { getInvito: vi.fn(), accettaInvito: vi.fn() };
keycloak = {
authenticated: false,
login: vi.fn().mockResolvedValue(undefined),
updateToken: vi.fn().mockResolvedValue(true)
};
router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
});
it('carica e mostra i dettagli di un invito valido, pronto per essere accettato', async () => {
invitiApi.getInvito.mockReturnValue(of(invitoValido));
await setup();
expect(invitiApi.getInvito).toHaveBeenCalledWith('token-abc');
expect(component.loading()).toBe(false);
expect(component.loadError()).toBeNull();
expect(component.invito()).toEqual(invitoValido);
expect(component.invito()?.valido).toBe(true);
});
it('carica un invito non valido (scaduto o già accettato): nessuna azione possibile', async () => {
invitiApi.getInvito.mockReturnValue(of({ ...invitoValido, valido: false }));
await setup();
expect(component.invito()?.valido).toBe(false);
expect(component.loadError()).toBeNull();
// il template nasconde il pulsante "Accetta invito" quando invito().valido è false;
// a livello di componente verifichiamo che accettare non abbia comunque effetto.
await component.accetta();
expect(invitiApi.accettaInvito).not.toHaveBeenCalled();
});
it('mostra un messaggio quando l\'invito non viene trovato (404)', async () => {
invitiApi.getInvito.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 404 })));
await setup();
expect(component.loadError()).toBe('Invito non trovato: verifica il link ricevuto.');
expect(component.invito()).toBeNull();
});
it("avvia login/registrazione Keycloak se l'utente non è autenticato e clicca \"Accetta invito\"", async () => {
invitiApi.getInvito.mockReturnValue(of(invitoValido));
keycloak.authenticated = false;
await setup();
await component.accetta();
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
expect(invitiApi.accettaInvito).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
it("accetta l'invito e reindirizza alla home se l'utente è autenticato", async () => {
invitiApi.getInvito.mockReturnValue(of(invitoValido));
invitiApi.accettaInvito.mockReturnValue(of({ organizationId: 'org-1', ruolo: 'Capi' }));
keycloak.authenticated = true;
await setup();
await component.accetta();
expect(invitiApi.accettaInvito).toHaveBeenCalledWith('token-abc');
// il token corrente non contiene ancora il claim "organization": va aggiornato prima del redirect.
expect(keycloak.updateToken).toHaveBeenCalledWith(-1);
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
expect(component.accepting()).toBe(false);
});
it("mostra un messaggio se l'accettazione fallisce e permette di ritentare senza ricaricare la pagina", async () => {
invitiApi.getInvito.mockReturnValue(of(invitoValido));
invitiApi.accettaInvito.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 })));
keycloak.authenticated = true;
await setup();
await component.accetta();
expect(component.acceptError()).toBe("L'invito è già stato accettato.");
expect(component.accepting()).toBe(false);
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { InvitiApiService, InvitoPubblico } from './inviti-api.service';
@Component({
selector: 'app-inviti',
imports: [MatButtonModule],
templateUrl: './inviti.html',
styleUrl: './inviti.css'
})
export class Inviti implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly invitiApi = inject(InvitiApiService);
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
private readonly token = this.route.snapshot.paramMap.get('token') ?? '';
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly invito = signal<InvitoPubblico | null>(null);
readonly accepting = signal(false);
readonly acceptError = signal<string | null>(null);
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
async ngOnInit(): Promise<void> {
await this.loadInvito();
}
private async loadInvito(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const invito = await firstValueFrom(this.invitiApi.getInvito(this.token));
this.invito.set(invito);
} catch (error) {
const httpError = error as HttpErrorResponse;
this.loadError.set(
httpError.status === 404
? 'Invito non trovato: verifica il link ricevuto.'
: "Impossibile caricare l'invito. Riprova più tardi."
);
} finally {
this.loading.set(false);
}
}
async accetta(): Promise<void> {
if (!this.isAuthenticated) {
// redirectUri riporta l'utente su questa stessa pagina di invito dopo login/registrazione.
await this.keycloak.login({ redirectUri: window.location.href });
return;
}
if (this.accepting()) {
return;
}
this.accepting.set(true);
this.acceptError.set(null);
try {
await firstValueFrom(this.invitiApi.accettaInvito(this.token));
// Il claim "organization" nel token sarà popolato solo al prossimo refresh.
await this.keycloak.updateToken(-1).catch(() => undefined);
this.accepting.set(false);
await this.router.navigateByUrl('/');
} catch (error) {
this.accepting.set(false);
this.acceptError.set(this.messageForAcceptError(error as HttpErrorResponse));
}
}
private messageForAcceptError(error: HttpErrorResponse): string {
switch (error.status) {
case 409:
return "L'invito è già stato accettato.";
case 410:
return "L'invito è scaduto.";
case 403:
return "Questo invito non è indirizzato al tuo account: accedi con l'email corretta.";
default:
return "Impossibile accettare l'invito. Riprova più tardi.";
}
}
}