Fix grafica scouthub-home-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-25 12:02:03 +02:00
parent 7e0544c5ab
commit 08271b8a15
60 changed files with 4305 additions and 149 deletions
+9
View File
@@ -1,11 +1,20 @@
FROM node:22-bookworm-slim AS build FROM node:22-bookworm-slim AS build
ARG KEYCLOAK_BASE_URL
ARG ORG_SERVICE_API_BASE_URL
ARG ATTIVITA_FE_BASE_URL
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN sed -i \
-e "s#__KEYCLOAK_BASE_URL__#${KEYCLOAK_BASE_URL}#" \
-e "s#__ORG_SERVICE_API_BASE_URL__#${ORG_SERVICE_API_BASE_URL}#" \
-e "s#__ATTIVITA_FE_BASE_URL__#${ATTIVITA_FE_BASE_URL}#" \
src/environments/environment.ts
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
+5 -4
View File
@@ -100,10 +100,11 @@ espongono:
Il realm `scouthub` viene importato automaticamente da `keycloak/realm-export.json` Il realm `scouthub` viene importato automaticamente da `keycloak/realm-export.json`
al primo avvio (`--import-realm`). al primo avvio (`--import-realm`).
2. Il client pubblico `scouthub-frontend` ha `redirectUris`/`webOrigins` che includono 2. Il client pubblico `scouthub-frontend` ha `redirectUris`/`webOrigins` che includono
sia `http://localhost:4200` (usato da `scouthub-attivita-fe`) sia `http://localhost:4200` (usato da `scouthub-attivita-fe`), `http://localhost:4201`
`http://localhost:4201` (porta di default di questo progetto, vedi sotto) — se si (porta di default di questo progetto, vedi sotto) e le porte di
cambia porta, aggiornare `keycloak/realm-export.json` di conseguenza e reimportare `scouthub-magazzino-fe`/`scouthub-eventi-fe` — il client è ora condiviso da tutti
il realm (o aggiornarlo da console admin Keycloak). e quattro i frontend. Se si cambia porta, aggiornare `keycloak/realm-export.json`
di conseguenza e reimportare il realm (o aggiornarlo da console admin Keycloak).
3. `provideKeycloakAngular()` (`src/app/core/auth/keycloak.provider.ts`) inizializza 3. `provideKeycloakAngular()` (`src/app/core/auth/keycloak.provider.ts`) inizializza
il client con `onLoad: 'check-sso'`: all'avvio l'app verifica in un iframe nascosto il client con `onLoad: 'check-sso'`: all'avvio l'app verifica in un iframe nascosto
se esiste già una sessione Keycloak, senza forzare un redirect immediato per le se esiste già una sessione Keycloak, senza forzare un redirect immediato per le
+143
View File
@@ -0,0 +1,143 @@
.app-shell {
min-height: 100%;
}
.app-shell__topbar {
background: var(--mat-sys-surface) !important;
color: var(--mat-sys-on-surface) !important;
border-bottom: 1px solid var(--mat-sys-outline-variant);
gap: 12px;
}
.app-shell__brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
}
.app-shell__logo {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 17px;
}
.app-shell__brand-name {
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 19px;
}
.app-shell__spacer {
flex: 1 1 auto;
}
.app-shell__login-btn {
border-radius: 999px !important;
}
.app-shell__user-btn {
border-radius: 999px !important;
display: flex;
align-items: center;
gap: 8px;
}
.app-shell__avatar {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--mat-sys-secondary);
color: var(--mat-sys-on-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.app-shell__body {
display: flex;
align-items: flex-start;
}
.app-shell__sidebar {
width: 216px;
flex: none;
padding: 22px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-shell__nav-link {
display: flex;
align-items: center;
gap: 11px;
padding: 11px 14px;
border-radius: 999px;
text-decoration: none;
color: var(--mat-sys-on-surface);
font-size: 14px;
}
.app-shell__nav-link:hover {
background: var(--mat-sys-surface-container-high);
}
.app-shell__nav-link--active {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
.app-shell__content {
flex: 1;
min-width: 0;
padding: 28px 24px 100px;
}
.app-shell__bottomnav {
display: none;
}
@media (max-width: 899px) {
.app-shell__sidebar {
display: none;
}
.app-shell__bottomnav {
display: flex;
justify-content: space-around;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--mat-sys-surface-container-high);
box-shadow: 0 -3px 10px rgba(46, 43, 37, 0.16);
padding: 8px 4px;
z-index: 40;
}
}
.app-shell__bottomnav-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 10px;
padding: 4px 10px;
border-radius: 12px;
text-decoration: none;
color: var(--mat-sys-on-surface-variant);
}
.app-shell__bottomnav-link--active {
color: var(--mat-sys-primary);
}
+52 -1
View File
@@ -1 +1,52 @@
<router-outlet /> @if (isStandalonePage) {
<router-outlet />
} @else {
<div class="app-shell">
<mat-toolbar class="app-shell__topbar">
<a class="app-shell__brand" routerLink="/">
<span class="app-shell__logo">S</span>
<span class="app-shell__brand-name">Scouthub</span>
</a>
<span class="app-shell__spacer"></span>
@if (!isAuthenticated) {
<button mat-flat-button class="app-shell__login-btn" type="button" (click)="login()">Accedi</button>
} @else {
<button mat-stroked-button class="app-shell__user-btn" type="button" [matMenuTriggerFor]="userMenu">
<span class="app-shell__avatar">{{ userInitials }}</span>
{{ userName }}
<mat-icon iconPositionEnd>expand_more</mat-icon>
</button>
<mat-menu #userMenu="matMenu">
<button mat-menu-item routerLink="/profilo">Profilo</button>
<button mat-menu-item (click)="logout()">Esci</button>
</mat-menu>
}
</mat-toolbar>
<div class="app-shell__body">
<nav class="app-shell__sidebar">
@for (item of navItems; track item.path) {
<a class="app-shell__nav-link" [routerLink]="item.path" routerLinkActive="app-shell__nav-link--active" [routerLinkActiveOptions]="{ exact: item.path === '/' }">
<mat-icon>{{ item.icon }}</mat-icon>
<span>{{ item.label }}</span>
</a>
}
</nav>
<main class="app-shell__content">
<router-outlet />
</main>
</div>
<nav class="app-shell__bottomnav">
@for (item of navItems; track item.path) {
<a class="app-shell__bottomnav-link" [routerLink]="item.path" routerLinkActive="app-shell__bottomnav-link--active" [routerLinkActiveOptions]="{ exact: item.path === '/' }">
<mat-icon>{{ item.icon }}</mat-icon>
<span>{{ item.label }}</span>
</a>
}
</nav>
</div>
}
+22 -4
View File
@@ -1,20 +1,38 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ {
// Pubblica: la home mostra i servizi disponibili anche a chi non è ancora autenticato.
path: '', path: '',
loadComponent: () => import('./home/home').then((m) => m.Home), loadComponent: () => import('./home/home').then((m) => m.Home)
canActivate: [authGuard]
}, },
{ {
path: 'crea-gruppo', path: 'crea-gruppo',
loadChildren: () => import('./crea-gruppo/crea-gruppo.routes').then((m) => m.CREA_GRUPPO_ROUTES) loadChildren: () => import('./crea-gruppo/crea-gruppo.routes').then((m) => m.CREA_GRUPPO_ROUTES)
}, },
{
path: 'gruppi',
loadChildren: () => import('./gruppi/gruppi.routes').then((m) => m.GRUPPI_ROUTES)
},
{
path: 'profilo',
loadChildren: () => import('./profilo/profilo.routes').then((m) => m.PROFILO_ROUTES)
},
{
path: 'richieste-creazione-gruppo',
loadChildren: () =>
import('./richieste-creazione-gruppo/richieste-creazione-gruppo.routes').then(
(m) => m.RICHIESTE_CREAZIONE_GRUPPO_ROUTES
)
},
{ {
// Pubblica: un invito deve poter essere visualizzato anche da chi non ha ancora un account. // Pubblica: un invito deve poter essere visualizzato anche da chi non ha ancora un account.
path: 'inviti', path: 'inviti',
loadChildren: () => import('./inviti/inviti.routes').then((m) => m.INVITI_ROUTES) loadChildren: () => import('./inviti/inviti.routes').then((m) => m.INVITI_ROUTES)
},
{
// Pubblica: un link di ingresso deve poter essere visualizzato anche da chi non ha ancora un account.
path: 'ingresso',
loadChildren: () => import('./ingresso/ingresso.routes').then((m) => m.INGRESSO_ROUTES)
} }
]; ];
+84 -4
View File
@@ -1,10 +1,90 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter, map } from 'rxjs/operators';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import Keycloak from 'keycloak-js';
import { extractRealmRoles, RUOLI_ORGANIZZAZIONE } from './core/auth/roles';
interface NavItem {
label: string;
path: string;
icon: string;
}
// Route pubbliche mostrate a schermo intero, senza topbar/sidebar (invito/ingresso via link).
const STANDALONE_URL_PREFIXES = ['/inviti', '/ingresso'];
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet, RouterLink, RouterLinkActive, MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css' styleUrl: './app.css'
}) })
export class App {} export class App {
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
private readonly currentUrl = toSignal(
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => event.urlAfterRedirects)
),
{ initialValue: this.router.url }
);
get isStandalonePage(): boolean {
const url = this.currentUrl();
return STANDALONE_URL_PREFIXES.some((prefix) => url.startsWith(prefix));
}
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
get userName(): string {
const token = this.keycloak.tokenParsed;
return (token?.['name'] as string | undefined) ?? (token?.['preferred_username'] as string | undefined) ?? '';
}
get userInitials(): string {
return this.userName
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
}
get navItems(): NavItem[] {
if (!this.isAuthenticated) {
return [{ label: 'Home', path: '/', icon: 'home' }];
}
const items: NavItem[] = [
{ label: 'Home', path: '/', icon: 'home' },
{ label: 'Profilo', path: '/profilo', icon: 'person' }
];
const roles = extractRealmRoles(this.keycloak.tokenParsed);
if (roles.includes('admin')) {
items.push({ label: 'Gruppi', path: '/gruppi', icon: 'groups' });
} else if (RUOLI_ORGANIZZAZIONE.some((ruolo) => roles.includes(ruolo))) {
items.push({ label: 'Il mio gruppo', path: '/gruppi', icon: 'groups' });
}
return items;
}
login(): void {
this.keycloak.login({ redirectUri: window.location.href });
}
logout(): void {
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
}
}
@@ -24,13 +24,13 @@ describe('authGuard', () => {
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('reindirizza a /crea-gruppo se l\'utente è autenticato ma senza organization', async () => { it('reindirizza a /profilo se l\'utente è autenticato ma senza organization', async () => {
setup({ authenticated: true, tokenParsed: {} }); setup({ authenticated: true, tokenParsed: {} });
const result = await TestBed.runInInjectionContext(() => authGuard(route, state)); const result = await TestBed.runInInjectionContext(() => authGuard(route, state));
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/crea-gruppo').toString()); expect((result as UrlTree).toString()).toBe(router.parseUrl('/profilo').toString());
}); });
it('lascia proseguire la navigazione se l\'utente è autenticato e ha una organization', async () => { it('lascia proseguire la navigazione se l\'utente è autenticato e ha una organization', async () => {
@@ -18,7 +18,7 @@ export async function isAccessAllowed(
} }
const hasOrganization = extractOrganizationFromToken(keycloak.tokenParsed) !== null; const hasOrganization = extractOrganizationFromToken(keycloak.tokenParsed) !== null;
return hasOrganization ? true : router.parseUrl('/crea-gruppo'); return hasOrganization ? true : router.parseUrl('/profilo');
} }
export const authGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAccessAllowed); export const authGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAccessAllowed);
@@ -0,0 +1,7 @@
import { KeycloakTokenParsed } from 'keycloak-js';
export const RUOLI_ORGANIZZAZIONE = ['capo-gruppo', 'capo-unita', 'censito'] as const;
export function extractRealmRoles(tokenParsed: KeycloakTokenParsed | undefined): string[] {
return tokenParsed?.realm_access?.roles ?? [];
}
@@ -1,7 +1,30 @@
.crea-gruppo { .crea-gruppo {
max-width: 480px; display: flex;
margin: 2rem auto; justify-content: center;
padding: 0 1rem; padding-top: 24px;
}
.crea-gruppo__card {
width: min(440px, 100%);
background: var(--mat-sys-surface-container);
border-radius: 32px;
padding: 32px;
box-shadow: 0 12px 32px rgba(46, 43, 37, 0.16);
display: flex;
flex-direction: column;
gap: 13px;
}
.crea-gruppo__title {
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 22px;
margin: 0;
}
.crea-gruppo__intro {
margin: 0;
font-size: 14px;
opacity: 0.8;
} }
.crea-gruppo__form { .crea-gruppo__form {
@@ -15,6 +38,19 @@
} }
.crea-gruppo__error { .crea-gruppo__error {
color: var(--mat-sys-error, #b3261e); color: var(--mat-sys-error);
margin: 0; margin: 0;
font-size: 13px;
}
.crea-gruppo__actions {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
.crea-gruppo__submit {
border-radius: 999px !important;
background: var(--mat-sys-primary) !important;
color: var(--mat-sys-on-primary) !important;
} }
@@ -1,26 +1,35 @@
<section class="crea-gruppo"> <section class="crea-gruppo">
<h1>Crea un nuovo gruppo scout</h1> <div class="crea-gruppo__card">
<p>Il tuo account non è ancora collegato a nessun gruppo scout: creane uno per continuare.</p> @if (isAdmin) {
<h1 class="crea-gruppo__title">Crea nuovo gruppo</h1>
<p class="crea-gruppo__intro">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> <form class="crea-gruppo__form" (submit)="$event.preventDefault(); submit()" novalidate>
<mat-form-field appearance="outline" class="crea-gruppo__field"> <mat-form-field appearance="outline" class="crea-gruppo__field">
<mat-label>Nome del gruppo scout</mat-label> <mat-label>Nome del gruppo scout</mat-label>
<input matInput [formControl]="nome" placeholder="Es. Agesci Milano 1" /> <input matInput [formControl]="nome" placeholder="Es. Agesci Milano 1" />
@if (nome.hasError('required')) { @if (nome.hasError('required')) {
<mat-error>Il nome del gruppo è obbligatorio.</mat-error> <mat-error>Il nome del gruppo è obbligatorio.</mat-error>
} @else if (nome.hasError('minlength')) { } @else if (nome.hasError('minlength')) {
<mat-error>Il nome deve avere almeno 3 caratteri.</mat-error> <mat-error>Il nome deve avere almeno 3 caratteri.</mat-error>
} @else if (nome.hasError('maxlength')) { } @else if (nome.hasError('maxlength')) {
<mat-error>Il nome non può superare i 100 caratteri.</mat-error> <mat-error>Il nome non può superare i 100 caratteri.</mat-error>
} }
</mat-form-field> </mat-form-field>
@if (errorMessage(); as message) { @if (errorMessage(); as message) {
<p class="crea-gruppo__error" role="alert">{{ message }}</p> <p class="crea-gruppo__error" role="alert">{{ message }}</p>
}
<div class="crea-gruppo__actions">
<button class="crea-gruppo__submit" mat-flat-button type="submit" [disabled]="submitting()">
{{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
</button>
</div>
</form>
} @else {
<h1 class="crea-gruppo__title">Accesso non consentito</h1>
<p role="alert">Questa pagina è riservata agli utenti con ruolo admin.</p>
} }
</div>
<button mat-flat-button color="primary" type="submit" [disabled]="submitting()">
{{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
</button>
</form>
</section> </section>
@@ -11,13 +11,12 @@ describe('CreaGruppo', () => {
let component: CreaGruppo; let component: CreaGruppo;
let fixture: ComponentFixture<CreaGruppo>; let fixture: ComponentFixture<CreaGruppo>;
let gruppiApi: { creaGruppo: ReturnType<typeof vi.fn> }; let gruppiApi: { creaGruppo: ReturnType<typeof vi.fn> };
let keycloak: { updateToken: ReturnType<typeof vi.fn> };
let router: { navigateByUrl: ReturnType<typeof vi.fn> }; let router: { navigateByUrl: ReturnType<typeof vi.fn> };
beforeEach(async () => { async function setup(roles: string[] = ['admin']): Promise<void> {
gruppiApi = { creaGruppo: vi.fn() }; gruppiApi = { creaGruppo: vi.fn() };
keycloak = { updateToken: vi.fn().mockResolvedValue(true) };
router = { navigateByUrl: vi.fn().mockResolvedValue(true) }; router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
const keycloak = { tokenParsed: { realm_access: { roles } } };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CreaGruppo], imports: [CreaGruppo],
@@ -31,13 +30,18 @@ describe('CreaGruppo', () => {
fixture = TestBed.createComponent(CreaGruppo); fixture = TestBed.createComponent(CreaGruppo);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); }
it('si crea correttamente', () => { it('si crea correttamente', async () => {
await setup();
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
describe('validazione del campo nome', () => { describe('validazione del campo nome', () => {
beforeEach(async () => {
await setup();
});
it('è invalido se vuoto (required)', () => { it('è invalido se vuoto (required)', () => {
component.nome.setValue(''); component.nome.setValue('');
expect(component.nome.hasError('required')).toBe(true); expect(component.nome.hasError('required')).toBe(true);
@@ -79,6 +83,10 @@ describe('CreaGruppo', () => {
}); });
describe('gestione errore 409 (nome già esistente)', () => { describe('gestione errore 409 (nome già esistente)', () => {
beforeEach(async () => {
await setup();
});
it('mostra il messaggio del backend e permette di correggere il nome senza ricaricare la pagina', async () => { it('mostra il messaggio del backend e permette di correggere il nome senza ricaricare la pagina', async () => {
component.nome.setValue('Agesci Milano 1'); component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue( gruppiApi.creaGruppo.mockReturnValue(
@@ -113,17 +121,40 @@ describe('CreaGruppo', () => {
}); });
describe('redirect dopo il successo', () => { describe('redirect dopo il successo', () => {
it('aggiorna forzatamente il token e reindirizza alla home dopo la creazione del gruppo', async () => { beforeEach(async () => {
await setup();
});
it('reindirizza alla home dopo la creazione del gruppo', async () => {
component.nome.setValue('Agesci Milano 1'); component.nome.setValue('Agesci Milano 1');
gruppiApi.creaGruppo.mockReturnValue(of({ orgId: 'org-1', gruppiCreati: ['Capi'] })); gruppiApi.creaGruppo.mockReturnValue(of({ orgId: 'org-1', gruppiCreati: ['Capi'] }));
await component.submit(); await component.submit();
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith('Agesci Milano 1'); expect(gruppiApi.creaGruppo).toHaveBeenCalledWith('Agesci Milano 1');
expect(keycloak.updateToken).toHaveBeenCalledWith(-1);
expect(router.navigateByUrl).toHaveBeenCalledWith('/'); expect(router.navigateByUrl).toHaveBeenCalledWith('/');
expect(component.submitting()).toBe(false); expect(component.submitting()).toBe(false);
expect(component.errorMessage()).toBeNull(); expect(component.errorMessage()).toBeNull();
}); });
}); });
describe('controllo del ruolo admin', () => {
beforeEach(async () => {
await setup(['censito']);
});
it('mostra il messaggio di accesso non consentito invece del form', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('form')).toBeNull();
expect(compiled.textContent).toContain('Accesso non consentito');
});
it('non chiama l\'API se l\'utente non ha il ruolo admin', async () => {
component.nome.setValue('Agesci Milano 1');
await component.submit();
expect(gruppiApi.creaGruppo).not.toHaveBeenCalled();
});
});
}); });
@@ -30,7 +30,15 @@ export class CreaGruppo {
readonly submitting = signal(false); readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null); readonly errorMessage = signal<string | null>(null);
get isAdmin(): boolean {
return (this.keycloak.tokenParsed?.realm_access?.roles ?? []).includes('admin');
}
async submit(): Promise<void> { async submit(): Promise<void> {
if (!this.isAdmin) {
return;
}
if (this.submitting()) { if (this.submitting()) {
return; return;
} }
@@ -45,9 +53,6 @@ export class CreaGruppo {
try { try {
await firstValueFrom(this.gruppiApi.creaGruppo(this.nome.value.trim())); 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); this.submitting.set(false);
await this.router.navigateByUrl('/'); await this.router.navigateByUrl('/');
} catch (error) { } catch (error) {
@@ -0,0 +1,44 @@
<style>
:host {
display: block;
}
h2[mat-dialog-title] {
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 19px;
}
mat-dialog-content p {
font-size: 14px;
opacity: 0.85;
}
.approva-richiesta-dialog__confirm-btn {
border-radius: 999px !important;
background: var(--mat-sys-primary) !important;
color: var(--mat-sys-on-primary) !important;
}
</style>
<h2 mat-dialog-title>Approva richiesta</h2>
<mat-dialog-content>
<p>Assegna un ruolo a {{ data.email }} per completare l'approvazione.</p>
<mat-form-field appearance="outline">
<mat-label>Ruolo</mat-label>
<mat-select [formControl]="ruoloControl">
@for (ruolo of data.ruoliSelezionabili; track ruolo) {
<mat-option [value]="ruolo">{{ ruolo }}</mat-option>
}
</mat-select>
@if (ruoloControl.hasError('required')) {
<mat-error>Seleziona un ruolo.</mat-error>
}
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="annulla()">Annulla</button>
<button class="approva-richiesta-dialog__confirm-btn" mat-flat-button type="button" (click)="conferma()">
Approva
</button>
</mat-dialog-actions>
@@ -0,0 +1,36 @@
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
export interface ApprovaRichiestaDialogData {
email: string;
ruoliSelezionabili: readonly string[];
}
@Component({
selector: 'app-approva-richiesta-dialog',
imports: [ReactiveFormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatSelectModule],
templateUrl: './approva-richiesta-dialog.html'
})
export class ApprovaRichiestaDialog {
readonly data = inject<ApprovaRichiestaDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<ApprovaRichiestaDialog, string | null>);
readonly ruoloControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
conferma(): void {
if (this.ruoloControl.invalid) {
this.ruoloControl.markAsTouched();
return;
}
this.dialogRef.close(this.ruoloControl.value);
}
annulla(): void {
this.dialogRef.close(null);
}
}
@@ -0,0 +1,31 @@
<style>
:host {
display: block;
border-radius: 32px;
}
h2[mat-dialog-title] {
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 19px;
}
mat-dialog-content {
font-size: 14px;
opacity: 0.85;
}
.confirm-dialog__confirm-btn {
border-radius: 999px !important;
background: var(--mat-sys-error) !important;
color: var(--mat-sys-on-error) !important;
}
</style>
<h2 mat-dialog-title>{{ data.titolo }}</h2>
<mat-dialog-content>{{ data.messaggio }}</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="annulla()">Annulla</button>
<button class="confirm-dialog__confirm-btn" mat-flat-button type="button" (click)="conferma()">
{{ data.confermaLabel ?? 'Conferma' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,27 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
export interface ConfirmDialogData {
titolo: string;
messaggio: string;
confermaLabel?: string;
}
@Component({
selector: 'app-confirm-dialog',
imports: [MatButtonModule, MatDialogModule],
templateUrl: './confirm-dialog.html'
})
export class ConfirmDialog {
readonly data = inject<ConfirmDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<ConfirmDialog>);
conferma(): void {
this.dialogRef.close(true);
}
annulla(): void {
this.dialogRef.close(false);
}
}
@@ -0,0 +1,29 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface GruppoListItem {
orgId: string;
nome: string;
regione: string | null;
}
export interface GruppoPubblicoListItem {
orgId: string;
nome: string;
}
@Injectable({ providedIn: 'root' })
export class GruppiApiService {
private readonly http = inject(HttpClient);
listGruppi(): Observable<GruppoListItem[]> {
return this.http.get<GruppoListItem[]>(`${environment.orgServiceApiBaseUrl}/gruppi`);
}
listGruppiPubblico(): Observable<GruppoPubblicoListItem[]> {
return this.http.get<GruppoPubblicoListItem[]>(`${environment.orgServiceApiBaseUrl}/gruppi/elenco-pubblico`);
}
}
@@ -0,0 +1,62 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, provideRouter, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import Keycloak from 'keycloak-js';
import { Observable, of } from 'rxjs';
import { OrganizationContextService } from '../core/organization-context.service';
import { gruppiListGuard } from './gruppi-list.guard';
const route = {} as ActivatedRouteSnapshot;
const state = {} as RouterStateSnapshot;
function setup(
roles: string[],
organizationContext: { currentOrganizationId: Observable<string | null> } = { currentOrganizationId: of(null) }
): void {
const keycloakMock = { tokenParsed: { realm_access: { roles } } };
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: Keycloak, useValue: keycloakMock },
{ provide: OrganizationContextService, useValue: organizationContext }
]
});
}
describe('gruppiListGuard', () => {
it('lascia proseguire la navigazione se l\'utente ha il ruolo admin', async () => {
setup(['admin']);
const result = await TestBed.runInInjectionContext(() => gruppiListGuard(route, state));
expect(result).toBe(true);
});
it('reindirizza a /gruppi/:orgId se l\'utente ha il ruolo capo-gruppo', async () => {
setup(['capo-gruppo'], { currentOrganizationId: of('org-123') });
const result = await TestBed.runInInjectionContext(() => gruppiListGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/gruppi/org-123').toString());
});
it('reindirizza a /profilo se il capo-gruppo non ha una organization nel token', async () => {
setup(['capo-gruppo'], { currentOrganizationId: of(null) });
const result = await TestBed.runInInjectionContext(() => gruppiListGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/profilo').toString());
});
it('reindirizza a /profilo per ruoli senza accesso alla sezione', async () => {
setup(['censito']);
const result = await TestBed.runInInjectionContext(() => gruppiListGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/profilo').toString());
});
});
@@ -0,0 +1,29 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { extractRealmRoles } from '../core/auth/roles';
import { OrganizationContextService } from '../core/organization-context.service';
// La lista di tutti i gruppi è riservata ad admin. Un capo-gruppo ha
// sempre e solo il proprio gruppo, quindi viene mandato direttamente al suo
// dettaglio. Gli altri ruoli non hanno accesso a questa sezione.
export const gruppiListGuard: CanActivateFn = async () => {
const router = inject(Router);
const keycloak = inject(Keycloak);
const organizationContext = inject(OrganizationContextService);
const roles = extractRealmRoles(keycloak.tokenParsed);
if (roles.includes('admin')) {
return true;
}
if (roles.includes('capo-gruppo')) {
const orgId = await firstValueFrom(organizationContext.currentOrganizationId);
return orgId ? router.parseUrl(`/gruppi/${orgId}`) : router.parseUrl('/profilo');
}
return router.parseUrl('/profilo');
};
@@ -0,0 +1,51 @@
.gruppi-list {
max-width: 900px;
}
.gruppi-list__header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.gruppi-list__title {
font-family: var(--mat-sys-headline-medium-font, Caprasimo, sans-serif);
font-size: 28px;
margin: 0;
}
.gruppi-list__crea-btn {
margin-left: auto;
border-radius: 999px !important;
background: var(--mat-sys-primary) !important;
color: var(--mat-sys-on-primary) !important;
}
.gruppi-list__error {
color: var(--mat-sys-error);
}
.gd-card--table {
background: var(--mat-sys-surface-container);
border-radius: 28px;
padding: 6px 20px;
overflow-x: auto;
}
.gruppi-list__table {
width: 100%;
min-width: 480px;
background: transparent;
}
.gruppi-list__link {
color: var(--mat-sys-on-surface);
font-weight: 600;
text-decoration: none;
}
.gruppi-list__link:hover {
text-decoration: underline;
}
@@ -0,0 +1,35 @@
<section class="gruppi-list">
<div class="gruppi-list__header">
<h1 class="gruppi-list__title">Tutti i gruppi</h1>
<button class="gruppi-list__crea-btn" mat-flat-button type="button" routerLink="/crea-gruppo">
Crea nuovo gruppo
</button>
</div>
@if (loading()) {
<p>Caricamento gruppi…</p>
} @else if (loadError(); as message) {
<p class="gruppi-list__error" role="alert">{{ message }}</p>
} @else if (gruppi().length === 0) {
<p>Nessun gruppo scout censito.</p>
} @else {
<div class="gd-card gd-card--table">
<table mat-table [dataSource]="gruppi()" class="gruppi-list__table">
<ng-container matColumnDef="nome">
<th mat-header-cell *matHeaderCellDef>Nome</th>
<td mat-cell *matCellDef="let gruppo">
<a class="gruppi-list__link" [routerLink]="['/gruppi', gruppo.orgId]">{{ gruppo.nome }}</a>
</td>
</ng-container>
<ng-container matColumnDef="regione">
<th mat-header-cell *matHeaderCellDef>Regione</th>
<td mat-cell *matCellDef="let gruppo">{{ gruppo.regione ?? '—' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
}
</section>
@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { GruppiApiService, GruppoListItem } from '../gruppi-api.service';
import { GruppiList } from './gruppi-list';
describe('GruppiList', () => {
let component: GruppiList;
let fixture: ComponentFixture<GruppiList>;
let gruppiApi: { listGruppi: ReturnType<typeof vi.fn> };
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [GruppiList],
providers: [provideRouter([]), { provide: GruppiApiService, useValue: gruppiApi }]
}).compileComponents();
fixture = TestBed.createComponent(GruppiList);
component = fixture.componentInstance;
await fixture.whenStable();
}
it('mostra la tabella dei gruppi dopo il caricamento', async () => {
const gruppi: GruppoListItem[] = [
{ orgId: 'org-1', nome: 'Agesci Milano 1', regione: 'Lombardia' },
{ orgId: 'org-2', nome: 'Agesci Roma 1', regione: null }
];
gruppiApi = { listGruppi: vi.fn().mockReturnValue(of(gruppi)) };
await setup();
expect(component.loading()).toBe(false);
expect(component.gruppi()).toEqual(gruppi);
const compiled = fixture.nativeElement as HTMLElement;
const links = compiled.querySelectorAll('td a');
expect(links.length).toBe(2);
expect(links[0].getAttribute('href')).toBe('/gruppi/org-1');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
gruppiApi = { listGruppi: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) };
await setup();
expect(component.loading()).toBe(false);
expect(component.loadError()).toBe("Impossibile caricare l'elenco dei gruppi. Riprova più tardi.");
});
it('mostra il bottone "Crea nuovo gruppo" verso /crea-gruppo', async () => {
gruppiApi = { listGruppi: vi.fn().mockReturnValue(of([])) };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const buttons = Array.from(compiled.querySelectorAll('button'));
expect(buttons.some((button) => button.textContent?.includes('Crea nuovo gruppo'))).toBe(true);
});
});
@@ -0,0 +1,41 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { GruppiApiService, GruppoListItem } from '../gruppi-api.service';
@Component({
selector: 'app-gruppi-list',
imports: [MatButtonModule, MatTableModule, RouterLink],
templateUrl: './gruppi-list.html',
styleUrl: './gruppi-list.css'
})
export class GruppiList implements OnInit {
private readonly gruppiApi = inject(GruppiApiService);
readonly displayedColumns = ['nome', 'regione'];
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly gruppi = signal<GruppoListItem[]>([]);
async ngOnInit(): Promise<void> {
await this.caricaGruppi();
}
private async caricaGruppi(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const gruppi = await firstValueFrom(this.gruppiApi.listGruppi());
this.gruppi.set(gruppi);
} catch {
this.loadError.set("Impossibile caricare l'elenco dei gruppi. Riprova più tardi.");
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,17 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
import { gruppiListGuard } from './gruppi-list.guard';
export const GRUPPI_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./gruppi-list/gruppi-list').then((m) => m.GruppiList),
canActivate: [requireAuthGuard, gruppiListGuard]
},
{
path: ':orgId',
loadComponent: () => import('./gruppo-dettaglio/gruppo-dettaglio').then((m) => m.GruppoDettaglio),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,193 @@
.gruppo-dettaglio {
max-width: 720px;
}
.gruppo-dettaglio__title {
font-family: var(--mat-sys-headline-medium-font, Caprasimo, sans-serif);
font-size: 27px;
margin: 0 0 22px;
}
.gd-tab-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.gd-tabbar {
display: inline-flex;
overflow: hidden;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 999px;
margin-bottom: 22px;
}
.gd-tabbar__tab {
padding: 9px 18px;
font-size: 13px;
cursor: pointer;
color: var(--mat-sys-on-surface);
white-space: nowrap;
}
.gd-tabbar__tab:hover {
background: var(--mat-sys-surface-container-high);
}
.gd-panel {
display: none;
}
#gd-tab-membri:checked ~ .gd-panel--membri,
#gd-tab-inviti:checked ~ .gd-panel--inviti,
#gd-tab-link:checked ~ .gd-panel--link,
#gd-tab-richieste:checked ~ .gd-panel--richieste {
display: block;
}
#gd-tab-membri:checked ~ .gd-tabbar .gd-tabbar__tab[for='gd-tab-membri'],
#gd-tab-inviti:checked ~ .gd-tabbar .gd-tabbar__tab[for='gd-tab-inviti'],
#gd-tab-link:checked ~ .gd-tabbar .gd-tabbar__tab[for='gd-tab-link'],
#gd-tab-richieste:checked ~ .gd-tabbar .gd-tabbar__tab[for='gd-tab-richieste'] {
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
}
.gd-card {
background: var(--mat-sys-surface-container);
border-radius: 28px;
padding: 20px 22px;
max-width: 560px;
}
.gd-card--table {
padding: 6px 20px;
max-width: none;
overflow-x: auto;
}
.gd-card__title {
font-family: var(--mat-sys-title-medium-font, Caprasimo, sans-serif);
font-size: 17px;
margin: 0 0 14px;
}
.gruppo-dettaglio__membri {
margin-bottom: 20px;
}
.gruppo-dettaglio__aggiungi-membro {
margin-top: 20px;
}
.gruppo-dettaglio__table {
width: 100%;
min-width: 480px;
background: transparent;
}
.gruppo-dettaglio__ruolo-select {
width: 160px;
margin-right: 0.5rem;
}
.gruppo-dettaglio__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 400px;
}
.gruppo-dettaglio__error {
color: var(--mat-sys-error);
font-size: 13px;
}
.gruppo-dettaglio__hint {
font-size: 13px;
opacity: 0.8;
max-width: 480px;
margin: 0 0 14px;
}
.gruppo-dettaglio__invito-conferma {
color: var(--mat-sys-primary);
font-size: 13px;
}
.gruppo-dettaglio__link-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.gruppo-dettaglio__link-field {
flex: 1;
min-width: 220px;
max-width: 480px;
}
.gd-primary-btn {
align-self: flex-start;
border-radius: 999px !important;
background: var(--mat-sys-primary) !important;
color: var(--mat-sys-on-primary) !important;
}
.gd-outline-btn {
border-radius: 999px !important;
}
.gd-danger-btn {
border-radius: 999px !important;
color: var(--mat-sys-error) !important;
}
.gd-empty-hint {
text-align: center;
padding: 40px 20px;
opacity: 0.55;
font-size: 13px;
}
.gd-richieste-list {
max-width: 560px;
display: flex;
flex-direction: column;
gap: 10px;
}
.gd-richiesta-row {
background: var(--mat-sys-surface-container);
border-radius: 20px;
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.gd-richiesta-row__email {
font-size: 14px;
font-weight: 600;
}
.gd-richiesta-row__meta {
font-size: 12px;
opacity: 0.55;
}
.gd-richiesta-row__azioni {
display: flex;
gap: 8px;
}
.gd-icon-btn--approve {
color: var(--mat-sys-secondary) !important;
}
.gd-icon-btn--reject {
color: var(--mat-sys-error) !important;
}
@@ -0,0 +1,283 @@
<section class="gruppo-dettaglio">
<h1 class="gruppo-dettaglio__title">Gruppo scout</h1>
<!--
Tab puramente visive: implementate con radio input nascosti + label, senza alcuno stato
Angular aggiuntivo (il componente .ts non è stato toccato). Tutte le sezioni restano nel DOM
con gli stessi binding di prima, cambia solo quale pannello è visibile.
-->
<input type="radio" name="gd-tab" id="gd-tab-membri" class="gd-tab-input" checked />
<input type="radio" name="gd-tab" id="gd-tab-inviti" class="gd-tab-input" />
<input type="radio" name="gd-tab" id="gd-tab-link" class="gd-tab-input" />
<input type="radio" name="gd-tab" id="gd-tab-richieste" class="gd-tab-input" />
<div class="gd-tabbar">
<label for="gd-tab-membri" class="gd-tabbar__tab">Membri</label>
@if (canManageMembri) {
<label for="gd-tab-inviti" class="gd-tabbar__tab">Inviti</label>
<label for="gd-tab-link" class="gd-tabbar__tab">Link d'ingresso</label>
<label for="gd-tab-richieste" class="gd-tabbar__tab">
Richieste
@if (richiesteIngresso().length > 0) {
({{ richiesteIngresso().length }})
}
</label>
}
</div>
<!-- TAB: MEMBRI -->
<div class="gd-panel gd-panel--membri">
<section class="gruppo-dettaglio__membri">
@if (loading()) {
<p>Caricamento membri…</p>
} @else if (loadError(); as message) {
<p class="gruppo-dettaglio__error" role="alert">{{ message }}</p>
} @else {
@if (actionError(); as message) {
<p class="gruppo-dettaglio__error" role="alert">{{ message }}</p>
}
<div class="gd-card gd-card--table">
<table mat-table [dataSource]="membri()" class="gruppo-dettaglio__table">
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef>Email</th>
<td mat-cell *matCellDef="let row">{{ row.email }}</td>
</ng-container>
<ng-container matColumnDef="ruolo">
<th mat-header-cell *matHeaderCellDef>Ruolo</th>
<td mat-cell *matCellDef="let row">
@if (canManageMembri) {
<mat-form-field appearance="outline" class="gruppo-dettaglio__ruolo-select">
<mat-select [formControl]="row.ruoloControl">
@for (ruolo of ruoliSelezionabili; track ruolo) {
<mat-option [value]="ruolo">{{ ruolo }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (row.ruoloControl.value !== row.ruolo) {
<button
mat-stroked-button
type="button"
[disabled]="ruoloSalvandoUserId() === row.userId"
(click)="confermaCambioRuolo(row)"
>
{{ ruoloSalvandoUserId() === row.userId ? 'Salvataggio…' : 'Conferma' }}
</button>
}
} @else {
{{ row.ruolo ?? '—' }}
}
</td>
</ng-container>
<ng-container matColumnDef="gruppoInterno">
<th mat-header-cell *matHeaderCellDef>Gruppo interno</th>
<td mat-cell *matCellDef="let row">{{ row.gruppoInterno ?? '—' }}</td>
</ng-container>
<ng-container matColumnDef="azioni">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row">
@if (canManageMembri) {
<button
mat-stroked-button
class="gd-danger-btn"
type="button"
[disabled]="rimuovendoUserId() === row.userId"
(click)="rimuovi(row)"
>
{{ rimuovendoUserId() === row.userId ? 'Rimozione…' : 'Rimuovi' }}
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
}
</section>
@if (isAdmin) {
<section class="gruppo-dettaglio__aggiungi-membro gd-card">
<h2 class="gd-card__title">Aggiungi membro già registrato</h2>
<form class="gruppo-dettaglio__form" (submit)="$event.preventDefault(); aggiungiMembro()" novalidate>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" [formControl]="emailControl" placeholder="persona@esempio.it" />
@if (emailControl.hasError('required')) {
<mat-error>L'email è obbligatoria.</mat-error>
} @else if (emailControl.hasError('email')) {
<mat-error>Inserisci un indirizzo email valido.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Ruolo</mat-label>
<mat-select [formControl]="ruoloControl">
@for (ruolo of ruoliSelezionabili; track ruolo) {
<mat-option [value]="ruolo">{{ ruolo }}</mat-option>
}
</mat-select>
@if (ruoloControl.hasError('required')) {
<mat-error>Seleziona un ruolo.</mat-error>
}
</mat-form-field>
@if (addMembroError(); as message) {
<p class="gruppo-dettaglio__error" role="alert">{{ message }}</p>
}
<button class="gd-primary-btn" mat-flat-button type="submit" [disabled]="addingMembro()">
{{ addingMembro() ? 'Aggiunta in corso…' : 'Aggiungi membro' }}
</button>
</form>
</section>
}
</div>
<!-- TAB: INVITI -->
@if (canManageMembri) {
<div class="gd-panel gd-panel--inviti">
<section class="gruppo-dettaglio__invito gd-card">
<p class="gruppo-dettaglio__hint">
La persona invitata riceverà un link per completare l'accesso: se non ha ancora un account su Scouthub
dovrà accedere o registrarsi al primo utilizzo del link, il flusso di accettazione dell'invito se ne
occupa già automaticamente.
</p>
<form class="gruppo-dettaglio__form" (submit)="$event.preventDefault(); invitaPerEmail()" novalidate>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" [formControl]="invitoEmailControl" placeholder="persona@esempio.it" />
@if (invitoEmailControl.hasError('required')) {
<mat-error>L'email è obbligatoria.</mat-error>
} @else if (invitoEmailControl.hasError('email')) {
<mat-error>Inserisci un indirizzo email valido.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Ruolo</mat-label>
<mat-select [formControl]="invitoRuoloControl">
@for (ruolo of ruoliSelezionabili; track ruolo) {
<mat-option [value]="ruolo">{{ ruolo }}</mat-option>
}
</mat-select>
@if (invitoRuoloControl.hasError('required')) {
<mat-error>Seleziona un ruolo.</mat-error>
}
</mat-form-field>
@if (invitoError(); as message) {
<p class="gruppo-dettaglio__error" role="alert">{{ message }}</p>
}
<button class="gd-primary-btn" mat-flat-button type="submit" [disabled]="invitando()">
{{ invitando() ? 'Invio in corso…' : 'Invita' }}
</button>
</form>
@if (invitoCreato()) {
<p class="gruppo-dettaglio__invito-conferma" role="status">
Invito creato, scade il {{ scadenzaInvitoFormattata }}.
</p>
}
</section>
</div>
}
<!-- TAB: LINK D'INGRESSO -->
@if (canManageMembri) {
<div class="gd-panel gd-panel--link">
<section class="gruppo-dettaglio__link-ingresso gd-card">
<p class="gruppo-dettaglio__hint">
Chi apre questo link può chiedere di entrare nel gruppo; la richiesta dovrà comunque essere approvata.
</p>
<button
class="gd-outline-btn"
mat-stroked-button
type="button"
[disabled]="generandoLinkIngresso()"
(click)="generaLinkIngresso()"
>
{{ generandoLinkIngresso() ? 'Generazione…' : 'Genera/mostra link di ingresso' }}
</button>
@if (linkIngressoError(); as message) {
<p class="gruppo-dettaglio__error" role="alert">{{ message }}</p>
}
@if (linkIngresso(); as link) {
<div class="gruppo-dettaglio__link-row">
<mat-form-field appearance="outline" class="gruppo-dettaglio__link-field">
<mat-label>Link di ingresso</mat-label>
<input matInput [value]="link.url" readonly />
</mat-form-field>
<button class="gd-outline-btn" mat-stroked-button type="button" (click)="copiaLinkIngresso()">Copia</button>
</div>
}
</section>
</div>
}
<!-- TAB: RICHIESTE -->
@if (canManageMembri) {
<div class="gd-panel gd-panel--richieste">
<section class="gruppo-dettaglio__richieste-ingresso">
@if (richiesteLoading()) {
<p>Caricamento richieste…</p>
} @else if (richiesteLoadError(); as message) {
<p class="gruppo-dettaglio__error" role="alert">{{ message }}</p>
} @else {
@if (richiesteActionError(); as message) {
<p class="gruppo-dettaglio__error" role="alert">{{ message }}</p>
}
@if (richiesteIngresso().length === 0) {
<p class="gd-empty-hint">Nessuna richiesta in sospeso</p>
} @else {
<div class="gd-richieste-list">
@for (richiesta of richiesteIngresso(); track richiesta.id) {
<div class="gd-richiesta-row">
<div>
<div class="gd-richiesta-row__email">{{ richiesta.email }}</div>
<div class="gd-richiesta-row__meta">
{{ origineLabel(richiesta.origine) }} · richiesta del {{ formattaDataRichiesta(richiesta.createdAt) }}
</div>
</div>
<div class="gd-richiesta-row__azioni">
<button
class="gd-icon-btn gd-icon-btn--approve"
mat-icon-button
type="button"
[disabled]="risolvendoRichiestaId() === richiesta.id"
(click)="approvaRichiesta(richiesta)"
aria-label="Approva richiesta"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</button>
<button
class="gd-icon-btn gd-icon-btn--reject"
mat-icon-button
type="button"
[disabled]="risolvendoRichiestaId() === richiesta.id"
(click)="rifiutaRichiesta(richiesta)"
aria-label="Rifiuta richiesta"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
</div>
}
</div>
}
}
</section>
</div>
}
</section>
@@ -0,0 +1,517 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import Keycloak from 'keycloak-js';
import { Observable, of, throwError } from 'rxjs';
import { OrganizationContextService } from '../../core/organization-context.service';
import { InvitiApiService } from '../inviti-api.service';
import { LinkIngressoApiService } from '../link-ingresso-api.service';
import { Membro, MembriApiService } from '../membri-api.service';
import { RichiestaIngressoPending, RichiesteIngressoApiService } from '../richieste-ingresso-api.service';
import { GruppoDettaglio } from './gruppo-dettaglio';
describe('GruppoDettaglio', () => {
let component: GruppoDettaglio;
let fixture: ComponentFixture<GruppoDettaglio>;
let membriApi: {
getMembri: ReturnType<typeof vi.fn>;
cambiaRuolo: ReturnType<typeof vi.fn>;
rimuovi: ReturnType<typeof vi.fn>;
aggiungi: ReturnType<typeof vi.fn>;
};
let invitiApi: { creaInvito: ReturnType<typeof vi.fn> };
let linkIngressoApi: { creaOTrovaLink: ReturnType<typeof vi.fn> };
let richiesteIngressoApi: { getRichieste: ReturnType<typeof vi.fn>; risolvi: ReturnType<typeof vi.fn> };
let snackBar: { open: ReturnType<typeof vi.fn> };
let dialog: { open: ReturnType<typeof vi.fn> };
const membriIniziali: Membro[] = [
{ userId: 'user-1', email: 'capo1@esempio.it', ruolo: 'capo-gruppo', gruppoInterno: 'Capi' },
{ userId: 'user-2', email: 'censito1@esempio.it', ruolo: 'censito', gruppoInterno: 'Reparto' }
];
const richiesteIniziali: RichiestaIngressoPending[] = [
{
id: 'richiesta-1',
userId: 'user-9',
email: 'nuovo1@esempio.it',
origine: 'LINK',
createdAt: '2026-07-01T00:00:00.000Z'
},
{
id: 'richiesta-2',
userId: 'user-10',
email: 'nuovo2@esempio.it',
origine: 'PROFILO',
createdAt: '2026-07-02T00:00:00.000Z'
}
];
async function setup(options: {
roles: string[];
orgId?: string;
currentOrganizationId?: Observable<string | null>;
membri?: Membro[];
richieste?: RichiestaIngressoPending[];
}): Promise<void> {
membriApi = {
getMembri: vi.fn().mockReturnValue(of(options.membri ?? membriIniziali)),
cambiaRuolo: vi.fn().mockReturnValue(of({ userId: 'user-2', ruolo: 'capo-unita' })),
rimuovi: vi.fn().mockReturnValue(of(undefined)),
aggiungi: vi.fn().mockReturnValue(of({ userId: 'user-3', email: 'nuovo@esempio.it', ruolo: 'censito' }))
};
invitiApi = {
creaInvito: vi.fn().mockReturnValue(of({ invitoId: 'invito-1', scadenza: '2026-08-01T00:00:00.000Z' }))
};
linkIngressoApi = {
creaOTrovaLink: vi.fn().mockReturnValue(of({ token: 'token-abc', url: 'http://localhost:4201/ingresso/token-abc' }))
};
richiesteIngressoApi = {
getRichieste: vi.fn().mockReturnValue(of(options.richieste ?? richiesteIniziali)),
risolvi: vi.fn().mockReturnValue(of({ id: 'richiesta-1', stato: 'APPROVATA' }))
};
snackBar = { open: vi.fn() };
dialog = { open: vi.fn() };
const keycloakMock = { tokenParsed: { realm_access: { roles: options.roles } } };
const organizationContextMock = {
currentOrganizationId: options.currentOrganizationId ?? of(null)
};
await TestBed.configureTestingModule({
imports: [GruppoDettaglio],
providers: [
{ provide: MembriApiService, useValue: membriApi },
{ provide: InvitiApiService, useValue: invitiApi },
{ provide: LinkIngressoApiService, useValue: linkIngressoApi },
{ provide: RichiesteIngressoApiService, useValue: richiesteIngressoApi },
{ provide: Keycloak, useValue: keycloakMock },
{ provide: OrganizationContextService, useValue: organizationContextMock },
{ provide: MatDialog, useValue: dialog },
{ provide: MatSnackBar, useValue: snackBar },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: { get: () => options.orgId ?? 'org-1' } } }
}
]
}).compileComponents();
fixture = TestBed.createComponent(GruppoDettaglio);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
}
it('carica ed espone l\'elenco dei membri', async () => {
await setup({ roles: ['admin'] });
expect(membriApi.getMembri).toHaveBeenCalledWith('org-1');
expect(component.loading()).toBe(false);
expect(component.membri().map((m) => m.email)).toEqual(['capo1@esempio.it', 'censito1@esempio.it']);
});
it('mostra un messaggio di errore se il caricamento dei membri fallisce', async () => {
membriApi = {
getMembri: vi.fn().mockReturnValue(throwError(() => new Error('network error'))),
cambiaRuolo: vi.fn(),
rimuovi: vi.fn(),
aggiungi: vi.fn()
};
invitiApi = { creaInvito: vi.fn() };
linkIngressoApi = { creaOTrovaLink: vi.fn() };
richiesteIngressoApi = { getRichieste: vi.fn().mockReturnValue(of([])), risolvi: vi.fn() };
snackBar = { open: vi.fn() };
dialog = { open: vi.fn() };
await TestBed.configureTestingModule({
imports: [GruppoDettaglio],
providers: [
{ provide: MembriApiService, useValue: membriApi },
{ provide: InvitiApiService, useValue: invitiApi },
{ provide: LinkIngressoApiService, useValue: linkIngressoApi },
{ provide: RichiesteIngressoApiService, useValue: richiesteIngressoApi },
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: ['admin'] } } } },
{ provide: OrganizationContextService, useValue: { currentOrganizationId: of(null) } },
{ provide: MatDialog, useValue: dialog },
{ provide: MatSnackBar, useValue: snackBar },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'org-1' } } } }
]
}).compileComponents();
fixture = TestBed.createComponent(GruppoDettaglio);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
expect(component.loadError()).toBe("Impossibile caricare l'elenco dei membri. Riprova più tardi.");
});
describe('permessi di gestione', () => {
it('admin può gestire i membri di qualsiasi gruppo', async () => {
await setup({ roles: ['admin'] });
expect(component.canManageMembri).toBe(true);
});
it('capo-gruppo può gestire i membri solo della propria organization', async () => {
await setup({ roles: ['capo-gruppo'], orgId: 'org-1', currentOrganizationId: of('org-1') });
expect(component.canManageMembri).toBe(true);
});
it('capo-gruppo non può gestire i membri di un\'altra organization', async () => {
await setup({ roles: ['capo-gruppo'], orgId: 'org-1', currentOrganizationId: of('org-2') });
expect(component.canManageMembri).toBe(false);
});
it('censito non può gestire i membri', async () => {
await setup({ roles: ['censito'], orgId: 'org-1', currentOrganizationId: of('org-1') });
expect(component.canManageMembri).toBe(false);
});
});
describe('form "aggiungi membro"', () => {
it('è visibile solo per admin', async () => {
await setup({ roles: ['admin'] });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.gruppo-dettaglio__aggiungi-membro form')).toBeTruthy();
});
it('non è visibile per capo-gruppo', async () => {
await setup({ roles: ['capo-gruppo'], orgId: 'org-1', currentOrganizationId: of('org-1') });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.gruppo-dettaglio__aggiungi-membro')).toBeNull();
});
it('mostra un messaggio dedicato se l\'utente non è ancora registrato (404)', async () => {
await setup({ roles: ['admin'] });
membriApi.aggiungi.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 404 })));
component.emailControl.setValue('sconosciuto@esempio.it');
component.ruoloControl.setValue('censito');
await component.aggiungiMembro();
expect(component.addMembroError()).toBe(
"Utente non trovato: usa l'invito per aggiungere una persona non ancora registrata."
);
});
it('aggiunge il membro alla lista in caso di successo', async () => {
await setup({ roles: ['admin'] });
component.emailControl.setValue('nuovo@esempio.it');
component.ruoloControl.setValue('censito');
await component.aggiungiMembro();
expect(membriApi.aggiungi).toHaveBeenCalledWith('org-1', { email: 'nuovo@esempio.it', ruolo: 'censito' });
expect(component.membri().some((m) => m.userId === 'user-3')).toBe(true);
expect(component.addMembroError()).toBeNull();
});
});
describe('cambio ruolo', () => {
it('chiama l\'API e aggiorna la riga quando il ruolo selezionato cambia', async () => {
await setup({ roles: ['admin'] });
const row = component.membri()[1];
row.ruoloControl.setValue('capo-unita');
await component.confermaCambioRuolo(row);
expect(membriApi.cambiaRuolo).toHaveBeenCalledWith('org-1', 'user-2', 'capo-unita');
expect(component.membri().find((m) => m.userId === 'user-2')?.ruolo).toBe('capo-unita');
});
it('mostra un errore se la chiamata fallisce', async () => {
await setup({ roles: ['admin'] });
membriApi.cambiaRuolo.mockReturnValue(throwError(() => new Error('fail')));
const row = component.membri()[1];
row.ruoloControl.setValue('capo-unita');
await component.confermaCambioRuolo(row);
expect(component.actionError()).toBe('Impossibile aggiornare il ruolo. Riprova più tardi.');
});
});
describe('rimozione membro', () => {
it('rimuove la riga solo se l\'utente conferma nel dialog', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
const row = component.membri()[1];
await component.rimuovi(row);
expect(dialog.open).toHaveBeenCalled();
expect(membriApi.rimuovi).toHaveBeenCalledWith('org-1', 'user-2');
expect(component.membri().some((m) => m.userId === 'user-2')).toBe(false);
});
it('non chiama l\'API se l\'utente annulla nel dialog', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(false) });
const row = component.membri()[1];
await component.rimuovi(row);
expect(membriApi.rimuovi).not.toHaveBeenCalled();
expect(component.membri().some((m) => m.userId === 'user-2')).toBe(true);
});
});
describe('invito per email', () => {
it('è visibile per admin', async () => {
await setup({ roles: ['admin'] });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Invita per email');
});
it('è visibile per capo-gruppo della propria organization', async () => {
await setup({ roles: ['capo-gruppo'], orgId: 'org-1', currentOrganizationId: of('org-1') });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Invita per email');
});
it('non è visibile per censito', async () => {
await setup({ roles: ['censito'], orgId: 'org-1', currentOrganizationId: of('org-1') });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).not.toContain('Invita per email');
});
it('chiama l\'API e mostra il messaggio di conferma con la data di scadenza', async () => {
await setup({ roles: ['admin'] });
component.invitoEmailControl.setValue('nuovo@esempio.it');
component.invitoRuoloControl.setValue('censito');
await component.invitaPerEmail();
expect(invitiApi.creaInvito).toHaveBeenCalledWith('org-1', { email: 'nuovo@esempio.it', ruolo: 'censito' });
expect(component.invitoCreato()).toEqual({ invitoId: 'invito-1', scadenza: '2026-08-01T00:00:00.000Z' });
expect(component.invitoError()).toBeNull();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Invito creato, scade il');
// Nessun link cliccabile: il backend non restituisce il token (vedi TODO nel componente).
expect(compiled.querySelector('.gruppo-dettaglio__invito-conferma a')).toBeNull();
});
it('non chiama l\'API se il form non è valido', async () => {
await setup({ roles: ['admin'] });
await component.invitaPerEmail();
expect(invitiApi.creaInvito).not.toHaveBeenCalled();
expect(component.invitoEmailControl.touched).toBe(true);
});
it('mostra un messaggio di errore se la chiamata fallisce', async () => {
await setup({ roles: ['admin'] });
invitiApi.creaInvito.mockReturnValue(throwError(() => new Error('fail')));
component.invitoEmailControl.setValue('nuovo@esempio.it');
component.invitoRuoloControl.setValue('censito');
await component.invitaPerEmail();
expect(component.invitoError()).toBe("Impossibile creare l'invito. Riprova più tardi.");
expect(component.invitoCreato()).toBeNull();
});
});
describe('link di ingresso', () => {
it('è visibile per admin', async () => {
await setup({ roles: ['admin'] });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Genera/mostra link di ingresso');
});
it('è visibile per capo-gruppo della propria organization', async () => {
await setup({ roles: ['capo-gruppo'], orgId: 'org-1', currentOrganizationId: of('org-1') });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Genera/mostra link di ingresso');
});
it('non è visibile per censito', async () => {
await setup({ roles: ['censito'], orgId: 'org-1', currentOrganizationId: of('org-1') });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).not.toContain('Genera/mostra link di ingresso');
});
it("chiama l'API (idempotente) e mostra l'url ricevuto in un campo di sola lettura", async () => {
await setup({ roles: ['admin'] });
await component.generaLinkIngresso();
expect(linkIngressoApi.creaOTrovaLink).toHaveBeenCalledWith('org-1');
expect(component.linkIngresso()).toEqual({ token: 'token-abc', url: 'http://localhost:4201/ingresso/token-abc' });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const input = compiled.querySelector('.gruppo-dettaglio__link-field input') as HTMLInputElement;
expect(input.value).toBe('http://localhost:4201/ingresso/token-abc');
expect(input.readOnly).toBe(true);
});
it('mostra un errore se la generazione del link fallisce', async () => {
await setup({ roles: ['admin'] });
linkIngressoApi.creaOTrovaLink.mockReturnValue(throwError(() => new Error('fail')));
await component.generaLinkIngresso();
expect(component.linkIngressoError()).toBe('Impossibile generare il link di ingresso. Riprova più tardi.');
expect(component.linkIngresso()).toBeNull();
});
it('copia il link negli appunti e mostra la snackbar di conferma', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
await setup({ roles: ['admin'] });
await component.generaLinkIngresso();
await component.copiaLinkIngresso();
expect(writeText).toHaveBeenCalledWith('http://localhost:4201/ingresso/token-abc');
expect(snackBar.open).toHaveBeenCalledWith('Copiato', undefined, { duration: 2000 });
});
});
describe('richieste di ingresso da approvare', () => {
it('carica e mostra la tabella con origine tradotta per admin', async () => {
await setup({ roles: ['admin'] });
expect(richiesteIngressoApi.getRichieste).toHaveBeenCalledWith('org-1');
expect(component.richiesteIngresso()).toEqual(richiesteIniziali);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('da link pubblico');
expect(compiled.textContent).toContain('richiesta diretta');
});
it('non viene caricata (né visibile) per censito', async () => {
await setup({ roles: ['censito'], orgId: 'org-1', currentOrganizationId: of('org-1') });
expect(richiesteIngressoApi.getRichieste).not.toHaveBeenCalled();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.gruppo-dettaglio__richieste-ingresso')).toBeNull();
});
it('mostra "Nessuna richiesta in attesa" se la lista è vuota', async () => {
await setup({ roles: ['admin'], richieste: [] });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna richiesta in attesa');
});
it('mostra un errore se il caricamento fallisce', async () => {
membriApi = {
getMembri: vi.fn().mockReturnValue(of(membriIniziali)),
cambiaRuolo: vi.fn(),
rimuovi: vi.fn(),
aggiungi: vi.fn()
};
invitiApi = { creaInvito: vi.fn() };
linkIngressoApi = { creaOTrovaLink: vi.fn() };
richiesteIngressoApi = {
getRichieste: vi.fn().mockReturnValue(throwError(() => new Error('fail'))),
risolvi: vi.fn()
};
snackBar = { open: vi.fn() };
dialog = { open: vi.fn() };
await TestBed.configureTestingModule({
imports: [GruppoDettaglio],
providers: [
{ provide: MembriApiService, useValue: membriApi },
{ provide: InvitiApiService, useValue: invitiApi },
{ provide: LinkIngressoApiService, useValue: linkIngressoApi },
{ provide: RichiesteIngressoApiService, useValue: richiesteIngressoApi },
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: ['admin'] } } } },
{ provide: OrganizationContextService, useValue: { currentOrganizationId: of(null) } },
{ provide: MatDialog, useValue: dialog },
{ provide: MatSnackBar, useValue: snackBar },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 'org-1' } } } }
]
}).compileComponents();
fixture = TestBed.createComponent(GruppoDettaglio);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
expect(component.richiesteLoadError()).toBe('Impossibile caricare le richieste di ingresso. Riprova più tardi.');
});
it('apre il dialog di approvazione, chiama l\'API con il ruolo scelto e rimuove la riga', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of('capo-unita') });
const richiesta = component.richiesteIngresso()[0];
await component.approvaRichiesta(richiesta);
expect(dialog.open).toHaveBeenCalled();
expect(richiesteIngressoApi.risolvi).toHaveBeenCalledWith('org-1', 'richiesta-1', {
esito: 'approvata',
ruolo: 'capo-unita'
});
expect(component.richiesteIngresso().some((r) => r.id === 'richiesta-1')).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith('Richiesta approvata', undefined, { duration: 2000 });
});
it('non chiama l\'API se il dialog di approvazione viene annullato', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(null) });
const richiesta = component.richiesteIngresso()[0];
await component.approvaRichiesta(richiesta);
expect(richiesteIngressoApi.risolvi).not.toHaveBeenCalled();
expect(component.richiesteIngresso().some((r) => r.id === 'richiesta-1')).toBe(true);
});
it('mostra un messaggio dedicato se l\'approvazione fallisce con 502 (Keycloak a metà)', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of('capo-unita') });
richiesteIngressoApi.risolvi.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 502 })));
const richiesta = component.richiesteIngresso()[0];
await component.approvaRichiesta(richiesta);
expect(component.richiesteActionError()).toBe(
"L'assegnazione su Keycloak non è andata a buon fine: la richiesta resta in attesa, riprova."
);
// in caso di errore la richiesta resta in coda: lo stato lato backend è ancora pending.
expect(component.richiesteIngresso().some((r) => r.id === 'richiesta-1')).toBe(true);
});
it('apre il dialog di conferma e rifiuta la richiesta', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
const richiesta = component.richiesteIngresso()[1];
await component.rifiutaRichiesta(richiesta);
expect(richiesteIngressoApi.risolvi).toHaveBeenCalledWith('org-1', 'richiesta-2', { esito: 'rifiutata' });
expect(component.richiesteIngresso().some((r) => r.id === 'richiesta-2')).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith('Richiesta rifiutata', undefined, { duration: 2000 });
});
it('non chiama l\'API se l\'utente annulla il dialog di rifiuto', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(false) });
const richiesta = component.richiesteIngresso()[1];
await component.rifiutaRichiesta(richiesta);
expect(richiesteIngressoApi.risolvi).not.toHaveBeenCalled();
expect(component.richiesteIngresso().some((r) => r.id === 'richiesta-2')).toBe(true);
});
});
});
@@ -0,0 +1,370 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, OnInit, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { extractRealmRoles, RUOLI_ORGANIZZAZIONE } from '../../core/auth/roles';
import { OrganizationContextService } from '../../core/organization-context.service';
import { ApprovaRichiestaDialog } from '../approva-richiesta-dialog/approva-richiesta-dialog';
import { ConfirmDialog } from '../confirm-dialog/confirm-dialog';
import { CreaInvitoResult, InvitiApiService } from '../inviti-api.service';
import { LinkIngressoApiService, LinkIngressoResult } from '../link-ingresso-api.service';
import { AggiungiMembroInput, Membro, MembriApiService } from '../membri-api.service';
import { RichiestaIngressoPending, RichiesteIngressoApiService } from '../richieste-ingresso-api.service';
interface MembroRow extends Membro {
ruoloControl: FormControl<string>;
}
@Component({
selector: 'app-gruppo-dettaglio',
imports: [ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatTableModule],
templateUrl: './gruppo-dettaglio.html',
styleUrl: './gruppo-dettaglio.css'
})
export class GruppoDettaglio implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly membriApi = inject(MembriApiService);
private readonly invitiApi = inject(InvitiApiService);
private readonly linkIngressoApi = inject(LinkIngressoApiService);
private readonly richiesteIngressoApi = inject(RichiesteIngressoApiService);
private readonly keycloak = inject(Keycloak);
private readonly organizationContext = inject(OrganizationContextService);
private readonly dialog = inject(MatDialog);
private readonly snackBar = inject(MatSnackBar);
readonly orgId = this.route.snapshot.paramMap.get('orgId') ?? '';
readonly ruoliSelezionabili = RUOLI_ORGANIZZAZIONE;
readonly displayedColumns = ['email', 'ruolo', 'gruppoInterno', 'azioni'];
private readonly currentOrganizationId = toSignal(this.organizationContext.currentOrganizationId, {
initialValue: null
});
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly membri = signal<MembroRow[]>([]);
readonly actionError = signal<string | null>(null);
readonly ruoloSalvandoUserId = signal<string | null>(null);
readonly rimuovendoUserId = signal<string | null>(null);
readonly emailControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email]
});
readonly ruoloControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
readonly addingMembro = signal(false);
readonly addMembroError = signal<string | null>(null);
readonly invitoEmailControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email]
});
readonly invitoRuoloControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
readonly invitando = signal(false);
readonly invitoError = signal<string | null>(null);
readonly invitoCreato = signal<CreaInvitoResult | null>(null);
readonly generandoLinkIngresso = signal(false);
readonly linkIngressoError = signal<string | null>(null);
readonly linkIngresso = signal<LinkIngressoResult | null>(null);
readonly displayedColumnsRichieste = ['email', 'origine', 'data', 'azioni'];
readonly richiesteLoading = signal(true);
readonly richiesteLoadError = signal<string | null>(null);
readonly richiesteIngresso = signal<RichiestaIngressoPending[]>([]);
readonly richiesteActionError = signal<string | null>(null);
readonly risolvendoRichiestaId = signal<string | null>(null);
get isAdmin(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes('admin');
}
get canManageMembri(): boolean {
if (this.isAdmin) {
return true;
}
return (
extractRealmRoles(this.keycloak.tokenParsed).includes('capo-gruppo') &&
this.currentOrganizationId() === this.orgId
);
}
async ngOnInit(): Promise<void> {
const caricamenti: Promise<void>[] = [this.caricaMembri()];
if (this.canManageMembri) {
caricamenti.push(this.caricaRichieste());
}
await Promise.all(caricamenti);
}
private toRow(membro: Membro): MembroRow {
return { ...membro, ruoloControl: new FormControl(membro.ruolo ?? '', { nonNullable: true }) };
}
private async caricaMembri(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const membri = await firstValueFrom(this.membriApi.getMembri(this.orgId));
this.membri.set(membri.map((membro) => this.toRow(membro)));
} catch {
this.loadError.set("Impossibile caricare l'elenco dei membri. Riprova più tardi.");
} finally {
this.loading.set(false);
}
}
async confermaCambioRuolo(row: MembroRow): Promise<void> {
const nuovoRuolo = row.ruoloControl.value;
this.actionError.set(null);
this.ruoloSalvandoUserId.set(row.userId);
try {
await firstValueFrom(this.membriApi.cambiaRuolo(this.orgId, row.userId, nuovoRuolo));
this.membri.update((rows) => rows.map((r) => (r.userId === row.userId ? { ...r, ruolo: nuovoRuolo } : r)));
} catch {
this.actionError.set('Impossibile aggiornare il ruolo. Riprova più tardi.');
} finally {
this.ruoloSalvandoUserId.set(null);
}
}
async rimuovi(row: MembroRow): Promise<void> {
const dialogRef = this.dialog.open(ConfirmDialog, {
data: {
titolo: 'Rimuovi membro',
messaggio: `Rimuovere ${row.email} dal gruppo? L'operazione non è reversibile.`,
confermaLabel: 'Rimuovi'
}
});
const confermato = await firstValueFrom(dialogRef.afterClosed());
if (!confermato) {
return;
}
this.actionError.set(null);
this.rimuovendoUserId.set(row.userId);
try {
await firstValueFrom(this.membriApi.rimuovi(this.orgId, row.userId));
this.membri.update((rows) => rows.filter((r) => r.userId !== row.userId));
} catch {
this.actionError.set('Impossibile rimuovere il membro. Riprova più tardi.');
} finally {
this.rimuovendoUserId.set(null);
}
}
async aggiungiMembro(): Promise<void> {
if (this.addingMembro()) {
return;
}
if (this.emailControl.invalid || this.ruoloControl.invalid) {
this.emailControl.markAsTouched();
this.ruoloControl.markAsTouched();
return;
}
this.addingMembro.set(true);
this.addMembroError.set(null);
const input: AggiungiMembroInput = { email: this.emailControl.value.trim(), ruolo: this.ruoloControl.value };
try {
const result = await firstValueFrom(this.membriApi.aggiungi(this.orgId, input));
this.membri.update((rows) => [
...rows,
this.toRow({ userId: result.userId, email: result.email, ruolo: result.ruolo, gruppoInterno: null })
]);
this.emailControl.reset('');
this.ruoloControl.reset('');
} catch (error) {
this.addMembroError.set(this.messageForAddError(error as HttpErrorResponse));
} finally {
this.addingMembro.set(false);
}
}
private messageForAddError(error: HttpErrorResponse): string {
if (error.status === 404) {
return "Utente non trovato: usa l'invito per aggiungere una persona non ancora registrata.";
}
return 'Impossibile aggiungere il membro. Riprova più tardi.';
}
async invitaPerEmail(): Promise<void> {
if (this.invitando()) {
return;
}
if (this.invitoEmailControl.invalid || this.invitoRuoloControl.invalid) {
this.invitoEmailControl.markAsTouched();
this.invitoRuoloControl.markAsTouched();
return;
}
this.invitando.set(true);
this.invitoError.set(null);
this.invitoCreato.set(null);
try {
const result = await firstValueFrom(
this.invitiApi.creaInvito(this.orgId, {
email: this.invitoEmailControl.value.trim(),
ruolo: this.invitoRuoloControl.value
})
);
this.invitoCreato.set(result);
this.invitoEmailControl.reset('');
this.invitoRuoloControl.reset('');
} catch {
this.invitoError.set("Impossibile creare l'invito. Riprova più tardi.");
} finally {
this.invitando.set(false);
}
}
get scadenzaInvitoFormattata(): string | null {
const risultato = this.invitoCreato();
if (!risultato) {
return null;
}
return new Date(risultato.scadenza).toLocaleDateString('it-IT', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
async generaLinkIngresso(): Promise<void> {
if (this.generandoLinkIngresso()) {
return;
}
this.generandoLinkIngresso.set(true);
this.linkIngressoError.set(null);
try {
// Idempotente lato backend: se esiste già un link attivo per questa org lo restituisce
// senza crearne uno nuovo.
const result = await firstValueFrom(this.linkIngressoApi.creaOTrovaLink(this.orgId));
this.linkIngresso.set(result);
} catch {
this.linkIngressoError.set('Impossibile generare il link di ingresso. Riprova più tardi.');
} finally {
this.generandoLinkIngresso.set(false);
}
}
async copiaLinkIngresso(): Promise<void> {
const link = this.linkIngresso();
if (!link) {
return;
}
await navigator.clipboard.writeText(link.url);
this.snackBar.open('Copiato', undefined, { duration: 2000 });
}
private async caricaRichieste(): Promise<void> {
this.richiesteLoading.set(true);
this.richiesteLoadError.set(null);
try {
const richieste = await firstValueFrom(this.richiesteIngressoApi.getRichieste(this.orgId));
this.richiesteIngresso.set(richieste);
} catch {
this.richiesteLoadError.set('Impossibile caricare le richieste di ingresso. Riprova più tardi.');
} finally {
this.richiesteLoading.set(false);
}
}
origineLabel(origine: string): string {
return origine === 'LINK' ? 'da link pubblico' : 'richiesta diretta';
}
formattaDataRichiesta(dataIso: string): string {
return new Date(dataIso).toLocaleDateString('it-IT', { day: 'numeric', month: 'long', year: 'numeric' });
}
async approvaRichiesta(richiesta: RichiestaIngressoPending): Promise<void> {
const dialogRef = this.dialog.open(ApprovaRichiestaDialog, {
data: { email: richiesta.email, ruoliSelezionabili: this.ruoliSelezionabili }
});
const ruolo = await firstValueFrom(dialogRef.afterClosed());
if (!ruolo) {
return;
}
this.richiesteActionError.set(null);
this.risolvendoRichiestaId.set(richiesta.id);
try {
await firstValueFrom(this.richiesteIngressoApi.risolvi(this.orgId, richiesta.id, { esito: 'approvata', ruolo }));
this.richiesteIngresso.update((rows) => rows.filter((r) => r.id !== richiesta.id));
this.snackBar.open('Richiesta approvata', undefined, { duration: 2000 });
} catch (error) {
this.richiesteActionError.set(this.messageForRichiestaActionError(error as HttpErrorResponse));
} finally {
this.risolvendoRichiestaId.set(null);
}
}
async rifiutaRichiesta(richiesta: RichiestaIngressoPending): Promise<void> {
const dialogRef = this.dialog.open(ConfirmDialog, {
data: {
titolo: 'Rifiuta richiesta',
messaggio: `Rifiutare la richiesta di ingresso di ${richiesta.email}?`,
confermaLabel: 'Rifiuta'
}
});
const confermato = await firstValueFrom(dialogRef.afterClosed());
if (!confermato) {
return;
}
this.richiesteActionError.set(null);
this.risolvendoRichiestaId.set(richiesta.id);
try {
await firstValueFrom(this.richiesteIngressoApi.risolvi(this.orgId, richiesta.id, { esito: 'rifiutata' }));
this.richiesteIngresso.update((rows) => rows.filter((r) => r.id !== richiesta.id));
this.snackBar.open('Richiesta rifiutata', undefined, { duration: 2000 });
} catch (error) {
this.richiesteActionError.set(this.messageForRichiestaActionError(error as HttpErrorResponse));
} finally {
this.risolvendoRichiestaId.set(null);
}
}
private messageForRichiestaActionError(error: HttpErrorResponse): string {
if (error.status === 502) {
return "L'assegnazione su Keycloak non è andata a buon fine: la richiesta resta in attesa, riprova.";
}
return 'Impossibile completare la richiesta. Riprova più tardi.';
}
}
@@ -0,0 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface CreaInvitoInput {
email: string;
ruolo: string;
}
export interface CreaInvitoResult {
invitoId: string;
scadenza: string;
}
@Injectable({ providedIn: 'root' })
export class InvitiApiService {
private readonly http = inject(HttpClient);
creaInvito(orgId: string, input: CreaInvitoInput): Observable<CreaInvitoResult> {
return this.http.post<CreaInvitoResult>(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/inviti`, input);
}
}
@@ -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 LinkIngressoResult {
token: string;
url: string;
}
@Injectable({ providedIn: 'root' })
export class LinkIngressoApiService {
private readonly http = inject(HttpClient);
creaOTrovaLink(orgId: string): Observable<LinkIngressoResult> {
return this.http.post<LinkIngressoResult>(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/link-ingresso`, {});
}
}
@@ -0,0 +1,47 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface Membro {
userId: string;
email: string;
ruolo: string | null;
gruppoInterno: string | null;
}
export interface AggiungiMembroInput {
email: string;
ruolo: string;
}
export interface AggiungiMembroResult {
userId: string;
email: string;
ruolo: string;
}
@Injectable({ providedIn: 'root' })
export class MembriApiService {
private readonly http = inject(HttpClient);
getMembri(orgId: string): Observable<Membro[]> {
return this.http.get<Membro[]>(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/membri`);
}
cambiaRuolo(orgId: string, userId: string, ruolo: string): Observable<{ userId: string; ruolo: string }> {
return this.http.put<{ userId: string; ruolo: string }>(
`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/membri/${userId}/ruolo`,
{ ruolo }
);
}
rimuovi(orgId: string, userId: string): Observable<void> {
return this.http.delete<void>(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/membri/${userId}`);
}
aggiungi(orgId: string, input: AggiungiMembroInput): Observable<AggiungiMembroResult> {
return this.http.post<AggiungiMembroResult>(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/membri`, input);
}
}
@@ -0,0 +1,52 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface RichiestaIngressoPending {
id: string;
userId: string;
email: string;
origine: string;
createdAt: string;
}
export interface RisolviRichiestaInput {
esito: 'approvata' | 'rifiutata';
ruolo?: string;
}
export interface RisolviRichiestaResult {
id: string;
stato: string;
}
export interface CreaRichiestaIngressoResult {
richiestaId: string;
}
@Injectable({ providedIn: 'root' })
export class RichiesteIngressoApiService {
private readonly http = inject(HttpClient);
getRichieste(orgId: string): Observable<RichiestaIngressoPending[]> {
return this.http.get<RichiestaIngressoPending[]>(
`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/richieste-ingresso`
);
}
richiediIngresso(orgId: string): Observable<CreaRichiestaIngressoResult> {
return this.http.post<CreaRichiestaIngressoResult>(
`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/richieste-ingresso`,
{}
);
}
risolvi(orgId: string, id: string, input: RisolviRichiestaInput): Observable<RisolviRichiestaResult> {
return this.http.put<RisolviRichiestaResult>(
`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/richieste-ingresso/${id}`,
input
);
}
}
+100 -22
View File
@@ -1,39 +1,117 @@
.dashboard { .home {
display: flex; max-width: 940px;
flex-direction: column;
gap: 1.5rem;
padding: 2rem;
max-width: 640px;
margin: 0 auto; margin: 0 auto;
} }
.servizi { .home__title {
display: flex; font-family: var(--mat-sys-headline-large-font, Caprasimo, sans-serif);
flex-direction: column; font-size: 30px;
gap: 0.75rem; margin: 0 0 6px;
}
.home__subtitle {
margin: 0 0 24px;
font-size: 15px;
opacity: 0.75;
max-width: 520px;
}
.servizi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
} }
.servizio-card { .servizio-card {
background: var(--mat-sys-surface-container);
border-radius: 32px;
padding: 22px;
box-shadow: 0 1px 2px rgba(46, 43, 37, 0.14);
}
.servizio-card__link {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 10px;
padding: 1rem 1.25rem;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0.5rem;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
transition: box-shadow 0.2s ease;
} }
.servizio-card:hover { .servizio-card__icon {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); width: 44px;
height: 44px;
border-radius: 50%;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
display: flex;
align-items: center;
justify-content: center;
} }
.servizio-card__titolo { .servizio-card__title {
font-weight: 600; font-family: var(--mat-sys-title-medium-font, Caprasimo, sans-serif);
font-size: 17px;
margin: 0;
} }
.servizio-card__descrizione { .servizio-card__desc {
font-size: 0.875rem; margin: 0;
opacity: 0.75; font-size: 13px;
opacity: 0.8;
}
.servizio-card__cta {
align-self: flex-start;
font-family: var(--mat-sys-label-large-font, Caprasimo, sans-serif);
font-size: 13px;
padding: 8px 18px;
border-radius: 999px;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
}
.servizio-card__avviso {
margin-top: 10px;
background: var(--mat-sys-primary-container);
border-radius: 14px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.servizio-card__avviso span {
font-size: 12px;
color: var(--mat-sys-on-primary-container);
}
.amministrazione {
margin-top: 40px;
}
.amministrazione__title {
font-family: var(--mat-sys-title-medium-font, Caprasimo, sans-serif);
font-size: 19px;
margin: 0 0 10px;
}
.amministrazione__links {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.amministrazione__link {
font-size: 13px;
padding: 8px 16px;
border-radius: 999px;
border: 1px solid var(--mat-sys-outline-variant);
text-decoration: none;
color: var(--mat-sys-on-surface);
}
.amministrazione__link:hover {
background: var(--mat-sys-surface-container-high);
} }
+41 -10
View File
@@ -1,13 +1,44 @@
<section class="dashboard"> <section class="home">
<h1>{{ organizationName() ?? 'Il tuo gruppo scout' }}</h1> <h1 class="home__title">Benvenuto in Scouthub</h1>
<section class="servizi"> @if (!isAuthenticated) {
<h2>Servizi</h2> <p class="home__subtitle">I servizi del tuo gruppo scout, in un unico posto.</p>
<a class="servizio-card" [href]="attivitaAppBaseUrl"> } @else {
<span class="servizio-card__titolo">Attività</span> <p class="home__subtitle">Ecco i servizi a tua disposizione.</p>
<span class="servizio-card__descrizione">Catalogo e ricerca delle attività scout</span> }
</a>
</section>
<button mat-stroked-button type="button" (click)="logout()">Esci</button> <div class="servizi-grid">
@for (servizio of servizi; track servizio.id) {
<div class="servizio-card">
<a class="servizio-card__link" [href]="servizio.url">
<span class="servizio-card__icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16v16H4z" />
<path d="M8 9h8M8 13h8M8 17h5" />
</svg>
</span>
<h2 class="servizio-card__title">{{ servizio.nome }}</h2>
<p class="servizio-card__desc">{{ servizio.descrizione }}</p>
<span class="servizio-card__cta">Apri</span>
</a>
@if (!isAuthenticated && servizio.messaggioAccesso) {
<div class="servizio-card__avviso">
<span>{{ servizio.messaggioAccesso }}</span>
<button mat-stroked-button type="button" (click)="login()">Accedi</button>
</div>
}
</div>
}
</div>
@if (isAdmin) {
<section class="amministrazione">
<h2 class="amministrazione__title">Amministrazione</h2>
<nav class="amministrazione__links">
<a class="amministrazione__link" routerLink="/gruppi">Gruppi scout</a>
<a class="amministrazione__link" routerLink="/richieste-creazione-gruppo">Richieste di creazione gruppo</a>
</nav>
</section>
}
</section> </section>
+59 -26
View File
@@ -1,24 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js'; import Keycloak from 'keycloak-js';
import { Observable, of } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { OrganizationContextService } from '../core/organization-context.service';
import { Home } from './home'; import { Home } from './home';
describe('Home', () => { describe('Home', () => {
let component: Home; let component: Home;
let fixture: ComponentFixture<Home>; let fixture: ComponentFixture<Home>;
let organizationContext: { currentOrganizationName: Observable<string | null> }; let keycloak: {
let keycloak: { logout: ReturnType<typeof vi.fn> }; authenticated: boolean | undefined;
login: ReturnType<typeof vi.fn>;
tokenParsed?: { realm_access?: { roles?: string[] } };
};
async function setup(): Promise<void> { async function setup(): Promise<void> {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Home], imports: [Home],
providers: [ providers: [provideRouter([]), { provide: Keycloak, useValue: keycloak }]
{ provide: OrganizationContextService, useValue: organizationContext },
{ provide: Keycloak, useValue: keycloak }
]
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(Home); fixture = TestBed.createComponent(Home);
@@ -27,39 +26,73 @@ describe('Home', () => {
await fixture.whenStable(); await fixture.whenStable();
} }
beforeEach(() => {
organizationContext = { currentOrganizationName: of('Agesci Milano 1') };
keycloak = { logout: vi.fn() };
});
it('si crea correttamente', async () => { it('si crea correttamente', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup(); await setup();
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('mostra il nome del gruppo scout corrente', async () => { it('mostra la card del servizio Attività puntando a attivitaFeBaseUrl', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup(); await setup();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Agesci Milano 1'); const link = compiled.querySelector('a.servizio-card__link') as HTMLAnchorElement;
});
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).toBeTruthy();
expect(link.getAttribute('href')).toBe(environment.attivitaAppBaseUrl); expect(link.getAttribute('href')).toBe(environment.attivitaFeBaseUrl);
}); });
it('invoca il logout di Keycloak al click sul pulsante "Esci"', async () => { it('mostra l\'avviso di accesso e il bottone "Accedi" se l\'utente non è autenticato', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup(); await setup();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('button') as HTMLButtonElement; expect(compiled.querySelector('.servizio-card__avviso')?.textContent).toContain(
'Accedi per poter aggiungere attività'
);
const button = compiled.querySelector('.servizio-card button') as HTMLButtonElement;
expect(button).toBeTruthy();
button.click(); button.click();
expect(keycloak.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin + '/' }); expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
});
it('non mostra l\'avviso di accesso se l\'utente è già autenticato', async () => {
keycloak = { authenticated: true, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.servizio-card__avviso')).toBeNull();
expect(compiled.querySelector('.servizio-card button')).toBeNull();
});
describe('sezione amministrazione', () => {
it('è visibile con i link a /gruppi e /richieste-creazione-gruppo per admin', async () => {
keycloak = {
authenticated: true,
login: vi.fn(),
tokenParsed: { realm_access: { roles: ['admin'] } }
};
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Amministrazione');
const links = Array.from(compiled.querySelectorAll('.amministrazione__links a')) as HTMLAnchorElement[];
expect(links.map((a) => a.getAttribute('href'))).toEqual(['/gruppi', '/richieste-creazione-gruppo']);
});
it('non è visibile per un utente senza ruolo admin', async () => {
keycloak = {
authenticated: true,
login: vi.fn(),
tokenParsed: { realm_access: { roles: ['censito'] } }
};
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).not.toContain('Amministrazione');
});
}); });
}); });
+33 -8
View File
@@ -1,25 +1,50 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js'; import Keycloak from 'keycloak-js';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { OrganizationContextService } from '../core/organization-context.service'; import { extractRealmRoles } from '../core/auth/roles';
export interface ServizioCard {
id: string;
nome: string;
descrizione: string;
url: string;
messaggioAccesso?: string;
}
const SERVIZI: ServizioCard[] = [
{
id: 'attivita',
nome: 'Scouthub Attività',
descrizione: 'Catalogo e ricerca delle attività scout',
url: environment.attivitaFeBaseUrl,
messaggioAccesso: 'Accedi per poter aggiungere attività'
}
];
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [MatButtonModule], imports: [MatButtonModule, MatCardModule, RouterLink],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.css' styleUrl: './home.css'
}) })
export class Home { export class Home {
private readonly organizationContext = inject(OrganizationContextService);
private readonly keycloak = inject(Keycloak); private readonly keycloak = inject(Keycloak);
readonly organizationName = toSignal(this.organizationContext.currentOrganizationName, { initialValue: null }); readonly servizi = SERVIZI;
readonly attivitaAppBaseUrl = environment.attivitaAppBaseUrl;
logout(): void { get isAuthenticated(): boolean {
this.keycloak.logout({ redirectUri: window.location.origin + '/' }); return this.keycloak.authenticated ?? false;
}
get isAdmin(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes('admin');
}
login(): void {
this.keycloak.login({ redirectUri: window.location.href });
} }
} }
@@ -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 LinkIngressoPubblico {
nomeGruppo: string;
gruppoId: string;
}
export interface RichiediIngressoResult {
richiestaId: string;
}
@Injectable({ providedIn: 'root' })
export class IngressoApiService {
private readonly http = inject(HttpClient);
getLinkIngresso(token: string): Observable<LinkIngressoPubblico> {
return this.http.get<LinkIngressoPubblico>(`${environment.orgServiceApiBaseUrl}/link-ingresso/${token}`);
}
richiediIngresso(token: string): Observable<RichiediIngressoResult> {
return this.http.post<RichiediIngressoResult>(
`${environment.orgServiceApiBaseUrl}/link-ingresso/${token}/richiedi`,
{}
);
}
}
@@ -0,0 +1,117 @@
:host {
display: block;
min-height: 100vh;
position: relative;
overflow: hidden;
background: var(--mat-sys-background);
color: var(--mat-sys-on-background);
}
.ingresso {
min-height: 100vh;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.ingresso__blob {
position: absolute;
border-radius: 50%;
z-index: 0;
}
.ingresso__blob--top {
top: -120px;
right: -120px;
width: 340px;
height: 340px;
background: var(--mat-sys-secondary-container);
}
.ingresso__blob--bottom {
bottom: -160px;
left: -140px;
width: 380px;
height: 380px;
background: var(--mat-sys-primary-container);
}
.ingresso__card {
position: relative;
z-index: 1;
width: min(440px, 100%);
background: var(--mat-sys-surface-container);
border-radius: 40px;
padding: 40px 32px;
box-shadow: 0 12px 32px rgba(46, 43, 37, 0.22);
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.ingresso__brand {
display: flex;
align-items: center;
gap: 8px;
opacity: 0.7;
}
.ingresso__logo {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--mat-sys-secondary);
color: var(--mat-sys-on-secondary);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mat-sys-label-large-font, Caprasimo, sans-serif);
font-size: 13px;
}
.ingresso__brand-name {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ingresso__title {
font-family: var(--mat-sys-headline-medium-font, Caprasimo, sans-serif);
font-size: 28px;
margin: 8px 0 0;
line-height: 1.15;
}
.ingresso__desc {
margin: 0;
font-size: 15px;
opacity: 0.85;
line-height: 1.55;
}
.ingresso__group-name {
color: var(--mat-sys-secondary);
}
.ingresso__submit-btn {
width: 100%;
margin-top: 8px;
border-radius: 999px !important;
background: var(--mat-sys-secondary) !important;
color: var(--mat-sys-on-secondary) !important;
}
.ingresso__error {
color: var(--mat-sys-error);
font-size: 14px;
}
.ingresso__conferma {
color: var(--mat-sys-primary);
font-size: 14px;
}
@@ -0,0 +1,35 @@
<section class="ingresso">
<div class="ingresso__blob ingresso__blob--top" aria-hidden="true"></div>
<div class="ingresso__blob ingresso__blob--bottom" aria-hidden="true"></div>
<div class="ingresso__card">
<div class="ingresso__brand">
<span class="ingresso__logo">S</span>
<span class="ingresso__brand-name">Scouthub</span>
</div>
@if (loading()) {
<p>Caricamento…</p>
} @else if (loadError(); as message) {
<p class="ingresso__error" role="alert">{{ message }}</p>
} @else if (link(); as linkData) {
<h1 class="ingresso__title">Richiedi di entrare</h1>
<p class="ingresso__desc">
Chiedi di entrare nel gruppo <strong class="ingresso__group-name">{{ linkData.nomeGruppo }}</strong>: la
richiesta dovrà essere approvata da un responsabile del gruppo.
</p>
@if (richiestaInviata()) {
<p class="ingresso__conferma" role="status">Richiesta inviata, in attesa di approvazione.</p>
} @else {
@if (richiestaError(); as errMessage) {
<p class="ingresso__error" role="alert">{{ errMessage }}</p>
}
<button class="ingresso__submit-btn" mat-flat-button (click)="richiediIngresso()" [disabled]="richiedendo()">
{{ richiedendo() ? 'Invio in corso…' : (isAuthenticated ? 'Richiedi di entrare' : 'Accedi e richiedi di entrare') }}
</button>
}
}
</div>
</section>
@@ -0,0 +1,8 @@
import { Routes } from '@angular/router';
export const INGRESSO_ROUTES: Routes = [
{
path: ':token',
loadComponent: () => import('./ingresso').then((m) => m.Ingresso)
}
];
@@ -0,0 +1,108 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { Ingresso } from './ingresso';
import { IngressoApiService, LinkIngressoPubblico } from './ingresso-api.service';
describe('Ingresso', () => {
let component: Ingresso;
let fixture: ComponentFixture<Ingresso>;
let ingressoApi: { getLinkIngresso: ReturnType<typeof vi.fn>; richiediIngresso: ReturnType<typeof vi.fn> };
let keycloak: { authenticated: boolean; login: ReturnType<typeof vi.fn> };
const linkValido: LinkIngressoPubblico = { nomeGruppo: 'Agesci Milano 1', gruppoId: 'org-1' };
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Ingresso],
providers: [
{ provide: IngressoApiService, useValue: ingressoApi },
{ provide: Keycloak, useValue: keycloak },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ token: 'token-abc' }) } }
}
]
}).compileComponents();
fixture = TestBed.createComponent(Ingresso);
component = fixture.componentInstance;
await component.ngOnInit();
}
beforeEach(() => {
ingressoApi = { getLinkIngresso: vi.fn(), richiediIngresso: vi.fn() };
keycloak = { authenticated: false, login: vi.fn().mockResolvedValue(undefined) };
});
it('carica e mostra il nome del gruppo per un link valido', async () => {
ingressoApi.getLinkIngresso.mockReturnValue(of(linkValido));
await setup();
expect(ingressoApi.getLinkIngresso).toHaveBeenCalledWith('token-abc');
expect(component.loading()).toBe(false);
expect(component.loadError()).toBeNull();
expect(component.link()).toEqual(linkValido);
});
it('mostra un messaggio chiaro senza azioni disponibili se il link non è valido (404)', async () => {
ingressoApi.getLinkIngresso.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 404 })));
await setup();
expect(component.loadError()).toBe('Questo link di ingresso non è valido o non è più attivo.');
expect(component.link()).toBeNull();
});
it('avvia il login se l\'utente non è autenticato e clicca "Richiedi di entrare"', async () => {
ingressoApi.getLinkIngresso.mockReturnValue(of(linkValido));
keycloak.authenticated = false;
await setup();
await component.richiediIngresso();
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
expect(ingressoApi.richiediIngresso).not.toHaveBeenCalled();
});
it("invia la richiesta e mostra il messaggio di conferma se l'utente è autenticato", async () => {
ingressoApi.getLinkIngresso.mockReturnValue(of(linkValido));
ingressoApi.richiediIngresso.mockReturnValue(of({ richiestaId: 'richiesta-1' }));
keycloak.authenticated = true;
await setup();
await component.richiediIngresso();
expect(ingressoApi.richiediIngresso).toHaveBeenCalledWith('token-abc');
expect(component.richiestaInviata()).toBe(true);
expect(component.richiedendo()).toBe(false);
});
it('mostra un messaggio dedicato se la richiesta è già pendente (409), senza reload', async () => {
ingressoApi.getLinkIngresso.mockReturnValue(of(linkValido));
ingressoApi.richiediIngresso.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 })));
keycloak.authenticated = true;
await setup();
await component.richiediIngresso();
expect(component.richiestaError()).toBe('Hai già una richiesta di ingresso in attesa per questo gruppo.');
expect(component.richiestaInviata()).toBe(false);
expect(component.richiedendo()).toBe(false);
});
it('mostra un messaggio generico per errori diversi da 409', async () => {
ingressoApi.getLinkIngresso.mockReturnValue(of(linkValido));
ingressoApi.richiediIngresso.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
keycloak.authenticated = true;
await setup();
await component.richiediIngresso();
expect(component.richiestaError()).toBe('Impossibile inviare la richiesta. Riprova più tardi.');
});
});
@@ -0,0 +1,89 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { IngressoApiService, LinkIngressoPubblico } from './ingresso-api.service';
@Component({
selector: 'app-ingresso',
imports: [MatButtonModule],
templateUrl: './ingresso.html',
styleUrl: './ingresso.css'
})
export class Ingresso implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly ingressoApi = inject(IngressoApiService);
private readonly keycloak = inject(Keycloak);
private readonly token = this.route.snapshot.paramMap.get('token') ?? '';
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly link = signal<LinkIngressoPubblico | null>(null);
readonly richiedendo = signal(false);
readonly richiestaError = signal<string | null>(null);
readonly richiestaInviata = signal(false);
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
async ngOnInit(): Promise<void> {
await this.caricaLink();
}
private async caricaLink(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const link = await firstValueFrom(this.ingressoApi.getLinkIngresso(this.token));
this.link.set(link);
} catch (error) {
const httpError = error as HttpErrorResponse;
this.loadError.set(
httpError.status === 404
? 'Questo link di ingresso non è valido o non è più attivo.'
: 'Impossibile caricare il link di ingresso. Riprova più tardi.'
);
} finally {
this.loading.set(false);
}
}
async richiediIngresso(): Promise<void> {
if (!this.isAuthenticated) {
// redirectUri riporta l'utente su questa stessa pagina dopo login/registrazione.
await this.keycloak.login({ redirectUri: window.location.href });
return;
}
if (this.richiedendo()) {
return;
}
this.richiedendo.set(true);
this.richiestaError.set(null);
try {
await firstValueFrom(this.ingressoApi.richiediIngresso(this.token));
this.richiestaInviata.set(true);
} catch (error) {
this.richiestaError.set(this.messageForRichiestaError(error as HttpErrorResponse));
} finally {
this.richiedendo.set(false);
}
}
private messageForRichiestaError(error: HttpErrorResponse): string {
if (error.status === 409) {
return 'Hai già una richiesta di ingresso in attesa per questo gruppo.';
}
return 'Impossibile inviare la richiesta. Riprova più tardi.';
}
}
+107 -4
View File
@@ -1,9 +1,112 @@
:host {
display: block;
min-height: 100vh;
position: relative;
overflow: hidden;
background: var(--mat-sys-background);
color: var(--mat-sys-on-background);
}
.inviti { .inviti {
max-width: 480px; min-height: 100vh;
margin: 2rem auto; position: relative;
padding: 0 1rem; overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.inviti__blob {
position: absolute;
border-radius: 50%;
z-index: 0;
}
.inviti__blob--top {
top: -120px;
right: -120px;
width: 340px;
height: 340px;
background: var(--mat-sys-primary-container);
}
.inviti__blob--bottom {
bottom: -160px;
left: -140px;
width: 380px;
height: 380px;
background: var(--mat-sys-secondary-container);
}
.inviti__card {
position: relative;
z-index: 1;
width: min(440px, 100%);
background: var(--mat-sys-surface-container);
border-radius: 40px;
padding: 40px 32px;
box-shadow: 0 12px 32px rgba(46, 43, 37, 0.22);
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.inviti__brand {
display: flex;
align-items: center;
gap: 8px;
opacity: 0.7;
}
.inviti__logo {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mat-sys-label-large-font, Caprasimo, sans-serif);
font-size: 13px;
}
.inviti__brand-name {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.inviti__title {
font-family: var(--mat-sys-headline-medium-font, Caprasimo, sans-serif);
font-size: 28px;
margin: 8px 0 0;
line-height: 1.15;
}
.inviti__desc {
margin: 0;
font-size: 15px;
opacity: 0.85;
line-height: 1.55;
}
.inviti__group-name {
color: var(--mat-sys-primary);
}
.inviti__accept-btn {
width: 100%;
margin-top: 8px;
border-radius: 999px !important;
background: var(--mat-sys-primary) !important;
color: var(--mat-sys-on-primary) !important;
} }
.inviti__error { .inviti__error {
color: var(--mat-sys-error, #b3261e); color: var(--mat-sys-error);
font-size: 14px;
} }
+31 -18
View File
@@ -1,24 +1,37 @@
<section class="inviti"> <section class="inviti">
@if (loading()) { <div class="inviti__blob inviti__blob--top" aria-hidden="true"></div>
<p>Caricamento invito…</p> <div class="inviti__blob inviti__blob--bottom" aria-hidden="true"></div>
} @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) { <div class="inviti__card">
<p class="inviti__error" role="alert"> <div class="inviti__brand">
Questo invito non è più valido: potrebbe essere scaduto o già stato accettato. <span class="inviti__logo">S</span>
<span class="inviti__brand-name">Scouthub</span>
</div>
@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 class="inviti__title">Sei stato invitato</h1>
<p class="inviti__desc">
Unisciti al gruppo <strong class="inviti__group-name">{{ invitoData.nomeGruppo }}</strong> su Scouthub come
<strong>{{ invitoData.ruolo }}</strong>.
</p> </p>
} @else {
@if (acceptError(); as errMessage) {
<p class="inviti__error" role="alert">{{ errMessage }}</p>
}
<button mat-flat-button color="primary" (click)="accetta()" [disabled]="accepting()"> @if (!invitoData.valido) {
{{ accepting() ? 'Accettazione in corso…' : 'Accetta invito' }} <p class="inviti__error" role="alert">
</button> 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 class="inviti__accept-btn" mat-flat-button (click)="accetta()" [disabled]="accepting()">
{{ accepting() ? 'Accettazione in corso…' : (isAuthenticated ? 'Accetta invito' : 'Accetta e crea il tuo account') }}
</button>
}
} }
} </div>
</section> </section>
@@ -0,0 +1,157 @@
.profilo {
max-width: 900px;
}
.profilo__title {
font-family: var(--mat-sys-headline-medium-font, Caprasimo, sans-serif);
font-size: 28px;
margin: 0 0 20px;
}
.profilo-card {
max-width: 420px;
background: var(--mat-sys-surface-container);
border-radius: 32px;
padding: 26px;
box-shadow: 0 1px 2px rgba(46, 43, 37, 0.14);
display: flex;
flex-direction: column;
gap: 12px;
}
.profilo-card__avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 22px;
text-transform: uppercase;
}
.profilo-card__avatar--admin {
background: var(--mat-sys-secondary);
color: var(--mat-sys-on-secondary);
}
.profilo-card__group-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.55;
}
.profilo-card__group-name {
font-size: 15px;
font-weight: 600;
margin-top: -8px;
}
.profilo-card__badge {
display: inline-flex;
align-self: flex-start;
font-size: 11px;
padding: 4px 12px;
border-radius: 12px;
background: var(--mat-sys-surface-container-lowest);
color: var(--mat-sys-on-surface-variant);
text-transform: capitalize;
}
.profilo-card__badge--leader {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
.profilo-card__badge--unit {
background: var(--mat-sys-secondary-container);
color: var(--mat-sys-on-secondary-container);
}
.profilo-paths {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: flex-start;
}
.profilo-path {
flex: 1;
min-width: 300px;
background: var(--mat-sys-surface-container);
border-radius: 32px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.profilo-paths__divider {
align-self: center;
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
padding: 0 4px;
}
.profilo-path__kicker {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.profilo-path__kicker--primary {
color: var(--mat-sys-primary);
}
.profilo-path__kicker--secondary {
color: var(--mat-sys-secondary);
}
.profilo-path__title {
font-family: var(--mat-sys-title-medium-font, Caprasimo, sans-serif);
font-size: 19px;
margin: 0;
}
.profilo-path__desc {
margin: 0;
font-size: 13px;
opacity: 0.8;
}
.profilo__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profilo-path__submit {
align-self: flex-start;
border-radius: 999px !important;
background: var(--mat-sys-primary) !important;
color: var(--mat-sys-on-primary) !important;
}
.profilo-path__submit--secondary {
background: var(--mat-sys-secondary) !important;
color: var(--mat-sys-on-secondary) !important;
}
.profilo__error {
color: var(--mat-sys-error);
font-size: 13px;
}
.profilo__conferma {
color: var(--mat-sys-primary);
font-size: 13px;
}
.profilo__hint {
font-size: 0.875rem;
opacity: 0.75;
}
@@ -0,0 +1,95 @@
<section class="profilo">
<h1 class="profilo__title">Il tuo profilo</h1>
@if (hasOrganization()) {
<div class="profilo-card">
<div class="profilo-card__avatar" [class.profilo-card__avatar--admin]="!ruoloCorrente">
{{ (organizationName() ?? 'Gruppo scout')[0] }}
</div>
<div class="profilo-card__group-label">Gruppo</div>
<div class="profilo-card__group-name">{{ organizationName() ?? 'Gruppo scout' }}</div>
<span
class="profilo-card__badge"
[class.profilo-card__badge--leader]="ruoloCorrente === 'capo-gruppo'"
[class.profilo-card__badge--unit]="ruoloCorrente === 'capo-unita'"
>
{{ ruoloCorrente ?? 'Ruolo non disponibile' }}
</span>
</div>
} @else {
<div class="profilo-paths">
<section class="profilo-path">
<span class="profilo-path__kicker profilo-path__kicker--primary">Percorso 1</span>
<h2 class="profilo-path__title">Entra in un gruppo esistente</h2>
<p class="profilo-path__desc">Cerca il tuo gruppo scout e richiedi di entrare a farne parte.</p>
@if (caricandoGruppi()) {
<p class="profilo__hint">Caricamento gruppi…</p>
} @else if (gruppiDisponibili().length === 0) {
<p class="profilo__hint">Nessun gruppo scout disponibile al momento.</p>
} @else {
<form class="profilo__form" (submit)="$event.preventDefault(); richiediIngresso()" novalidate>
<mat-form-field appearance="outline">
<mat-label>Gruppo scout</mat-label>
<mat-select [formControl]="gruppoSelezionatoControl">
@for (gruppo of gruppiDisponibili(); track gruppo.orgId) {
<mat-option [value]="gruppo.orgId">{{ gruppo.nome }}</mat-option>
}
</mat-select>
@if (gruppoSelezionatoControl.hasError('required')) {
<mat-error>Seleziona un gruppo.</mat-error>
}
</mat-form-field>
@if (richiestaIngressoError(); as message) {
<p class="profilo__error" role="alert">{{ message }}</p>
}
@if (richiestaIngressoInviata()) {
<p class="profilo__conferma" role="status">Richiesta inviata, in attesa di approvazione.</p>
}
<button class="profilo-path__submit" mat-flat-button type="submit" [disabled]="richiedendoIngresso()">
{{ richiedendoIngresso() ? 'Invio in corso…' : 'Richiedi di entrare' }}
</button>
</form>
}
</section>
<div class="profilo-paths__divider">oppure</div>
<section class="profilo-path">
<span class="profilo-path__kicker profilo-path__kicker--secondary">Percorso 2</span>
<h2 class="profilo-path__title">Non trovi il tuo gruppo?</h2>
<p class="profilo-path__desc">Richiedi all'amministratore centrale la creazione di un nuovo gruppo.</p>
<form class="profilo__form" (submit)="$event.preventDefault(); richiediCreazioneGruppo()" novalidate>
<mat-form-field appearance="outline">
<mat-label>Nome proposto</mat-label>
<input matInput [formControl]="nomePropostoControl" placeholder="Es. Agesci Milano 1" />
@if (nomePropostoControl.hasError('required')) {
<mat-error>Il nome proposto è obbligatorio.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Regione (opzionale)</mat-label>
<input matInput [formControl]="regioneControl" placeholder="Es. Lombardia" />
</mat-form-field>
@if (richiestaCreazioneError(); as message) {
<p class="profilo__error" role="alert">{{ message }}</p>
}
@if (richiestaCreazioneInviata()) {
<p class="profilo__conferma" role="status">Richiesta inviata, in attesa di approvazione.</p>
}
<button class="profilo-path__submit profilo-path__submit--secondary" mat-flat-button type="submit" [disabled]="richiedendoCreazione()">
{{ richiedendoCreazione() ? 'Invio in corso…' : 'Richiedi creazione gruppo' }}
</button>
</form>
</section>
</div>
}
</section>
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const PROFILO_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./profilo').then((m) => m.Profilo),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,198 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import Keycloak from 'keycloak-js';
import { Observable, of, throwError } from 'rxjs';
import { GruppiApiService } from '../gruppi/gruppi-api.service';
import { OrganizationContextService } from '../core/organization-context.service';
import { RichiesteIngressoApiService } from '../gruppi/richieste-ingresso-api.service';
import { Profilo } from './profilo';
import { RichiesteCreazioneGruppoApiService } from './richieste-creazione-gruppo-api.service';
describe('Profilo', () => {
let component: Profilo;
let fixture: ComponentFixture<Profilo>;
let gruppiApi: { listGruppiPubblico: ReturnType<typeof vi.fn> };
let richiesteIngressoApi: { richiediIngresso: ReturnType<typeof vi.fn> };
let richiesteCreazioneApi: { creaRichiesta: ReturnType<typeof vi.fn> };
async function setup(options: {
roles: string[];
hasOrganization: Observable<boolean>;
currentOrganizationName?: Observable<string | null>;
gruppiDisponibili?: Observable<{ orgId: string; nome: string }[]>;
}): Promise<void> {
gruppiApi = {
listGruppiPubblico: vi.fn().mockReturnValue(
options.gruppiDisponibili ?? of([{ orgId: 'org-1', nome: 'Gruppo Alfa' }])
)
};
richiesteIngressoApi = { richiediIngresso: vi.fn().mockReturnValue(of({ richiestaId: 'richiesta-1' })) };
richiesteCreazioneApi = { creaRichiesta: vi.fn().mockReturnValue(of({ id: 'richiesta-2' })) };
await TestBed.configureTestingModule({
imports: [Profilo],
providers: [
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: options.roles } } } },
{
provide: OrganizationContextService,
useValue: {
hasOrganization: options.hasOrganization,
currentOrganizationName: options.currentOrganizationName ?? of(null)
}
},
{ provide: GruppiApiService, useValue: gruppiApi },
{ provide: RichiesteIngressoApiService, useValue: richiesteIngressoApi },
{ provide: RichiesteCreazioneGruppoApiService, useValue: richiesteCreazioneApi }
]
}).compileComponents();
fixture = TestBed.createComponent(Profilo);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
fixture.detectChanges();
}
describe('caso A: utente con organization', () => {
it('mostra nome del gruppo e ruolo corrente in una card informativa', async () => {
await setup({
roles: ['capo-gruppo'],
hasOrganization: of(true),
currentOrganizationName: of('Agesci Milano 1')
});
expect(component.hasOrganization()).toBe(true);
expect(component.ruoloCorrente).toBe('capo-gruppo');
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Agesci Milano 1');
expect(compiled.textContent).toContain('capo-gruppo');
// nessuna azione disponibile in questo caso
expect(compiled.querySelector('form')).toBeNull();
});
});
describe('caso B: utente senza organization', () => {
it('mostra entrambe le sezioni', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Entra in un gruppo esistente');
expect(compiled.textContent).toContain('Non trovi il tuo gruppo?');
});
it('carica l\'elenco gruppi da GET /gruppi/elenco-pubblico e lo mostra nel select', async () => {
await setup({
roles: ['censito'],
hasOrganization: of(false),
gruppiDisponibili: of([
{ orgId: 'org-1', nome: 'Gruppo Alfa' },
{ orgId: 'org-2', nome: 'Gruppo Beta' }
])
});
expect(gruppiApi.listGruppiPubblico).toHaveBeenCalled();
expect(component.gruppiDisponibili()).toEqual([
{ orgId: 'org-1', nome: 'Gruppo Alfa' },
{ orgId: 'org-2', nome: 'Gruppo Beta' }
]);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('mat-select')).not.toBeNull();
});
it('mostra un messaggio se non ci sono gruppi disponibili', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false), gruppiDisponibili: of([]) });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessun gruppo scout disponibile al momento');
expect(compiled.querySelector('mat-select')).toBeNull();
});
describe('richiesta di ingresso in un gruppo esistente', () => {
it('chiama l\'API con il gruppo scelto e mostra il messaggio di conferma', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
component.gruppoSelezionatoControl.setValue('org-1');
await component.richiediIngresso();
expect(richiesteIngressoApi.richiediIngresso).toHaveBeenCalledWith('org-1');
expect(component.richiestaIngressoInviata()).toBe(true);
expect(component.richiestaIngressoError()).toBeNull();
});
it('mostra un messaggio dedicato se la richiesta è già pendente (409)', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
richiesteIngressoApi.richiediIngresso.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 })));
component.gruppoSelezionatoControl.setValue('org-1');
await component.richiediIngresso();
expect(component.richiestaIngressoError()).toBe('Hai già una richiesta di ingresso in attesa per questo gruppo.');
expect(component.richiestaIngressoInviata()).toBe(false);
});
it('non chiama l\'API se non è stato scelto nessun gruppo', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
await component.richiediIngresso();
expect(richiesteIngressoApi.richiediIngresso).not.toHaveBeenCalled();
expect(component.gruppoSelezionatoControl.touched).toBe(true);
});
});
describe('richiesta di creazione gruppo', () => {
it('chiama l\'API e mostra il messaggio di conferma', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
component.nomePropostoControl.setValue('Agesci Roma 2');
component.regioneControl.setValue('Lazio');
await component.richiediCreazioneGruppo();
expect(richiesteCreazioneApi.creaRichiesta).toHaveBeenCalledWith({
nomeProposto: 'Agesci Roma 2',
regione: 'Lazio'
});
expect(component.richiestaCreazioneInviata()).toBe(true);
expect(component.richiestaCreazioneError()).toBeNull();
});
it('invia regione undefined se non compilata', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
component.nomePropostoControl.setValue('Agesci Roma 2');
await component.richiediCreazioneGruppo();
expect(richiesteCreazioneApi.creaRichiesta).toHaveBeenCalledWith({
nomeProposto: 'Agesci Roma 2',
regione: undefined
});
});
it('mostra un messaggio dedicato se ha già una richiesta pendente (409)', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
richiesteCreazioneApi.creaRichiesta.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 })));
component.nomePropostoControl.setValue('Agesci Roma 2');
await component.richiediCreazioneGruppo();
expect(component.richiestaCreazioneError()).toBe('Hai già una richiesta di creazione gruppo in attesa.');
expect(component.richiestaCreazioneInviata()).toBe(false);
});
it('non chiama l\'API se il nome proposto è vuoto', async () => {
await setup({ roles: ['censito'], hasOrganization: of(false) });
await component.richiediCreazioneGruppo();
expect(richiesteCreazioneApi.creaRichiesta).not.toHaveBeenCalled();
expect(component.nomePropostoControl.touched).toBe(true);
});
});
});
});
+146
View File
@@ -0,0 +1,146 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { extractRealmRoles, RUOLI_ORGANIZZAZIONE } from '../core/auth/roles';
import { OrganizationContextService } from '../core/organization-context.service';
import { GruppiApiService, GruppoPubblicoListItem } from '../gruppi/gruppi-api.service';
import { RichiesteIngressoApiService } from '../gruppi/richieste-ingresso-api.service';
import { RichiesteCreazioneGruppoApiService } from './richieste-creazione-gruppo-api.service';
type GruppoOption = GruppoPubblicoListItem;
@Component({
selector: 'app-profilo',
imports: [ReactiveFormsModule, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './profilo.html',
styleUrl: './profilo.css'
})
export class Profilo {
private readonly keycloak = inject(Keycloak);
private readonly organizationContext = inject(OrganizationContextService);
private readonly gruppiApi = inject(GruppiApiService);
private readonly richiesteIngressoApi = inject(RichiesteIngressoApiService);
private readonly richiesteCreazioneApi = inject(RichiesteCreazioneGruppoApiService);
readonly hasOrganization = toSignal(this.organizationContext.hasOrganization, { initialValue: false });
readonly organizationName = toSignal(this.organizationContext.currentOrganizationName, { initialValue: null });
get ruoloCorrente(): string | null {
const roles = extractRealmRoles(this.keycloak.tokenParsed);
return RUOLI_ORGANIZZAZIONE.find((ruolo) => roles.includes(ruolo)) ?? null;
}
readonly gruppiDisponibili = signal<GruppoOption[]>([]);
readonly caricandoGruppi = signal(true);
readonly gruppoSelezionatoControl = new FormControl<string | null>(null, { validators: [Validators.required] });
readonly richiedendoIngresso = signal(false);
readonly richiestaIngressoError = signal<string | null>(null);
readonly richiestaIngressoInviata = signal(false);
constructor() {
this.caricaGruppiDisponibili();
}
private async caricaGruppiDisponibili(): Promise<void> {
try {
const gruppi = await firstValueFrom(this.gruppiApi.listGruppiPubblico());
this.gruppiDisponibili.set(gruppi);
} catch {
this.gruppiDisponibili.set([]);
} finally {
this.caricandoGruppi.set(false);
}
}
readonly nomePropostoControl = new FormControl('', {
nonNullable: true,
validators: [Validators.required]
});
readonly regioneControl = new FormControl('', { nonNullable: true });
readonly richiedendoCreazione = signal(false);
readonly richiestaCreazioneError = signal<string | null>(null);
readonly richiestaCreazioneInviata = signal(false);
async richiediIngresso(): Promise<void> {
if (this.richiedendoIngresso()) {
return;
}
if (this.gruppoSelezionatoControl.invalid) {
this.gruppoSelezionatoControl.markAsTouched();
return;
}
const orgId = this.gruppoSelezionatoControl.value!;
this.richiedendoIngresso.set(true);
this.richiestaIngressoError.set(null);
this.richiestaIngressoInviata.set(false);
try {
await firstValueFrom(this.richiesteIngressoApi.richiediIngresso(orgId));
this.richiestaIngressoInviata.set(true);
this.gruppoSelezionatoControl.reset(null);
} catch (error) {
this.richiestaIngressoError.set(this.messageForRichiestaIngressoError(error as HttpErrorResponse));
} finally {
this.richiedendoIngresso.set(false);
}
}
private messageForRichiestaIngressoError(error: HttpErrorResponse): string {
if (error.status === 409) {
return 'Hai già una richiesta di ingresso in attesa per questo gruppo.';
}
return 'Impossibile inviare la richiesta. Riprova più tardi.';
}
async richiediCreazioneGruppo(): Promise<void> {
if (this.richiedendoCreazione()) {
return;
}
if (this.nomePropostoControl.invalid) {
this.nomePropostoControl.markAsTouched();
return;
}
this.richiedendoCreazione.set(true);
this.richiestaCreazioneError.set(null);
this.richiestaCreazioneInviata.set(false);
try {
await firstValueFrom(
this.richiesteCreazioneApi.creaRichiesta({
nomeProposto: this.nomePropostoControl.value.trim(),
regione: this.regioneControl.value.trim() || undefined
})
);
this.richiestaCreazioneInviata.set(true);
this.nomePropostoControl.reset('');
this.regioneControl.reset('');
} catch (error) {
this.richiestaCreazioneError.set(this.messageForRichiestaCreazioneError(error as HttpErrorResponse));
} finally {
this.richiedendoCreazione.set(false);
}
}
private messageForRichiestaCreazioneError(error: HttpErrorResponse): string {
if (error.status === 409) {
return 'Hai già una richiesta di creazione gruppo in attesa.';
}
return 'Impossibile inviare la richiesta. Riprova più tardi.';
}
}
@@ -0,0 +1,52 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface CreaRichiestaCreazioneGruppoInput {
nomeProposto: string;
regione?: string;
}
export interface CreaRichiestaCreazioneGruppoResult {
id: string;
}
export interface RichiestaCreazioneGruppoPending {
id: string;
email: string;
nomeProposto: string;
regione: string | null;
createdAt: string;
}
export interface RisolviRichiestaCreazioneGruppoResult {
id: string;
stato: string;
}
@Injectable({ providedIn: 'root' })
export class RichiesteCreazioneGruppoApiService {
private readonly http = inject(HttpClient);
creaRichiesta(input: CreaRichiestaCreazioneGruppoInput): Observable<CreaRichiestaCreazioneGruppoResult> {
return this.http.post<CreaRichiestaCreazioneGruppoResult>(
`${environment.orgServiceApiBaseUrl}/richieste-creazione-gruppo`,
input
);
}
getRichieste(): Observable<RichiestaCreazioneGruppoPending[]> {
return this.http.get<RichiestaCreazioneGruppoPending[]>(
`${environment.orgServiceApiBaseUrl}/richieste-creazione-gruppo`
);
}
risolvi(id: string, esito: 'approvata' | 'rifiutata'): Observable<RisolviRichiestaCreazioneGruppoResult> {
return this.http.put<RisolviRichiestaCreazioneGruppoResult>(
`${environment.orgServiceApiBaseUrl}/richieste-creazione-gruppo/${id}`,
{ esito }
);
}
}
@@ -0,0 +1,59 @@
.richieste-creazione-gruppo {
max-width: 700px;
}
.richieste-creazione-gruppo__title {
font-family: var(--mat-sys-headline-medium-font, Caprasimo, sans-serif);
font-size: 28px;
margin: 0 0 20px;
}
.richieste-creazione-gruppo__error {
color: var(--mat-sys-error);
}
.richieste-creazione-gruppo__empty {
text-align: center;
padding: 40px 20px;
opacity: 0.55;
font-size: 13px;
}
.rcg-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rcg-row {
background: var(--mat-sys-surface-container);
border-radius: 20px;
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.rcg-row__nome {
font-size: 14px;
font-weight: 600;
}
.rcg-row__meta {
font-size: 12px;
opacity: 0.55;
}
.rcg-row__azioni {
display: flex;
gap: 8px;
}
.rcg-icon-btn--approve {
color: var(--mat-sys-secondary) !important;
}
.rcg-icon-btn--reject {
color: var(--mat-sys-error) !important;
}
@@ -0,0 +1,58 @@
<section class="richieste-creazione-gruppo">
@if (isAdmin) {
<h1 class="richieste-creazione-gruppo__title">Richieste di creazione gruppo</h1>
@if (loading()) {
<p>Caricamento richieste…</p>
} @else if (loadError(); as message) {
<p class="richieste-creazione-gruppo__error" role="alert">{{ message }}</p>
} @else {
@if (actionError(); as message) {
<p class="richieste-creazione-gruppo__error" role="alert">{{ message }}</p>
}
@if (richieste().length === 0) {
<p class="richieste-creazione-gruppo__empty">Nessuna richiesta in sospeso</p>
} @else {
<div class="rcg-list">
@for (richiesta of richieste(); track richiesta.id) {
<div class="rcg-row">
<div>
<div class="rcg-row__nome">{{ richiesta.nomeProposto }}</div>
<div class="rcg-row__meta">
{{ richiesta.email }} · {{ richiesta.regione ?? 'regione non indicata' }} · richiesta del
{{ formattaData(richiesta.createdAt) }}
</div>
</div>
<div class="rcg-row__azioni">
<button
class="rcg-icon-btn rcg-icon-btn--approve"
mat-icon-button
type="button"
[disabled]="risolvendoId() === richiesta.id"
(click)="approva(richiesta)"
aria-label="Approva richiesta"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</button>
<button
class="rcg-icon-btn rcg-icon-btn--reject"
mat-icon-button
type="button"
[disabled]="risolvendoId() === richiesta.id"
(click)="rifiuta(richiesta)"
aria-label="Rifiuta richiesta"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
</div>
}
</div>
}
}
} @else {
<h1 class="richieste-creazione-gruppo__title">Accesso non consentito</h1>
<p role="alert">Questa pagina è riservata agli utenti con ruolo admin.</p>
}
</section>
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const RICHIESTE_CREAZIONE_GRUPPO_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./richieste-creazione-gruppo').then((m) => m.RichiesteCreazioneGruppo),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,167 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import Keycloak from 'keycloak-js';
import { of, throwError } from 'rxjs';
import { RichiestaCreazioneGruppoPending, RichiesteCreazioneGruppoApiService } from '../profilo/richieste-creazione-gruppo-api.service';
import { RichiesteCreazioneGruppo } from './richieste-creazione-gruppo';
describe('RichiesteCreazioneGruppo', () => {
let component: RichiesteCreazioneGruppo;
let fixture: ComponentFixture<RichiesteCreazioneGruppo>;
let richiesteApi: { getRichieste: ReturnType<typeof vi.fn>; risolvi: ReturnType<typeof vi.fn> };
let dialog: { open: ReturnType<typeof vi.fn> };
let snackBar: { open: ReturnType<typeof vi.fn> };
const richiesteIniziali: RichiestaCreazioneGruppoPending[] = [
{ id: 'richiesta-1', email: 'mario@esempio.it', nomeProposto: 'Agesci Roma 2', regione: 'Lazio', createdAt: '2026-07-01T00:00:00.000Z' },
{ id: 'richiesta-2', email: 'anna@esempio.it', nomeProposto: 'Agesci Torino 3', regione: null, createdAt: '2026-07-02T00:00:00.000Z' }
];
async function setup(options: { roles: string[]; richieste?: RichiestaCreazioneGruppoPending[] }): Promise<void> {
richiesteApi = {
getRichieste: vi.fn().mockReturnValue(of(options.richieste ?? richiesteIniziali)),
risolvi: vi.fn().mockReturnValue(of({ id: 'richiesta-1', stato: 'APPROVATA' }))
};
dialog = { open: vi.fn() };
snackBar = { open: vi.fn() };
await TestBed.configureTestingModule({
imports: [RichiesteCreazioneGruppo],
providers: [
{ provide: RichiesteCreazioneGruppoApiService, useValue: richiesteApi },
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: options.roles } } } },
{ provide: MatDialog, useValue: dialog },
{ provide: MatSnackBar, useValue: snackBar }
]
}).compileComponents();
fixture = TestBed.createComponent(RichiesteCreazioneGruppo);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
}
describe('controllo del ruolo admin', () => {
it('mostra il messaggio di accesso non consentito e non chiama l\'API per un utente senza il ruolo', async () => {
await setup({ roles: ['censito'] });
expect(richiesteApi.getRichieste).not.toHaveBeenCalled();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Accesso non consentito');
expect(compiled.querySelector('table')).toBeNull();
});
});
describe('elenco richieste per admin', () => {
it('carica e mostra la tabella con email, nomeProposto, regione', async () => {
await setup({ roles: ['admin'] });
expect(richiesteApi.getRichieste).toHaveBeenCalled();
expect(component.richieste()).toEqual(richiesteIniziali);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('mario@esempio.it');
expect(compiled.textContent).toContain('Agesci Roma 2');
expect(compiled.textContent).toContain('Lazio');
});
it('mostra "Nessuna richiesta in attesa" se la lista è vuota', async () => {
await setup({ roles: ['admin'], richieste: [] });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna richiesta in attesa');
});
it('mostra un errore se il caricamento fallisce', async () => {
richiesteApi = { getRichieste: vi.fn().mockReturnValue(throwError(() => new Error('fail'))), risolvi: vi.fn() };
dialog = { open: vi.fn() };
snackBar = { open: vi.fn() };
await TestBed.configureTestingModule({
imports: [RichiesteCreazioneGruppo],
providers: [
{ provide: RichiesteCreazioneGruppoApiService, useValue: richiesteApi },
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: ['admin'] } } } },
{ provide: MatDialog, useValue: dialog },
{ provide: MatSnackBar, useValue: snackBar }
]
}).compileComponents();
fixture = TestBed.createComponent(RichiesteCreazioneGruppo);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
expect(component.loadError()).toBe('Impossibile caricare le richieste di creazione gruppo. Riprova più tardi.');
});
});
describe('approvazione', () => {
it('apre il dialog di conferma, chiama l\'API e rimuove la riga con snackbar di conferma', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
const richiesta = component.richieste()[0];
await component.approva(richiesta);
expect(dialog.open).toHaveBeenCalled();
expect(richiesteApi.risolvi).toHaveBeenCalledWith('richiesta-1', 'approvata');
expect(component.richieste().some((r) => r.id === 'richiesta-1')).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith('Richiesta approvata', undefined, { duration: 2000 });
});
it('non chiama l\'API se l\'utente annulla il dialog', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(false) });
const richiesta = component.richieste()[0];
await component.approva(richiesta);
expect(richiesteApi.risolvi).not.toHaveBeenCalled();
expect(component.richieste().some((r) => r.id === 'richiesta-1')).toBe(true);
});
it('mostra un messaggio dedicato se fallisce con 502 (Keycloak a metà) e mantiene la riga', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
richiesteApi.risolvi.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 502 })));
const richiesta = component.richieste()[0];
await component.approva(richiesta);
expect(component.actionError()).toBe(
"L'assegnazione su Keycloak non è andata a buon fine: la richiesta resta in attesa, riprova."
);
expect(component.richieste().some((r) => r.id === 'richiesta-1')).toBe(true);
});
});
describe('rifiuto', () => {
it('apre il dialog di conferma, chiama l\'API e rimuove la riga con snackbar di conferma', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
const richiesta = component.richieste()[1];
await component.rifiuta(richiesta);
expect(richiesteApi.risolvi).toHaveBeenCalledWith('richiesta-2', 'rifiutata');
expect(component.richieste().some((r) => r.id === 'richiesta-2')).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith('Richiesta rifiutata', undefined, { duration: 2000 });
});
it('non chiama l\'API se l\'utente annulla il dialog', async () => {
await setup({ roles: ['admin'] });
dialog.open.mockReturnValue({ afterClosed: () => of(false) });
const richiesta = component.richieste()[1];
await component.rifiuta(richiesta);
expect(richiesteApi.risolvi).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,127 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { extractRealmRoles } from '../core/auth/roles';
import { ConfirmDialog } from '../gruppi/confirm-dialog/confirm-dialog';
import {
RichiestaCreazioneGruppoPending,
RichiesteCreazioneGruppoApiService
} from '../profilo/richieste-creazione-gruppo-api.service';
@Component({
selector: 'app-richieste-creazione-gruppo',
imports: [MatButtonModule, MatTableModule],
templateUrl: './richieste-creazione-gruppo.html',
styleUrl: './richieste-creazione-gruppo.css'
})
export class RichiesteCreazioneGruppo implements OnInit {
private readonly richiesteApi = inject(RichiesteCreazioneGruppoApiService);
private readonly keycloak = inject(Keycloak);
private readonly dialog = inject(MatDialog);
private readonly snackBar = inject(MatSnackBar);
readonly displayedColumns = ['email', 'nomeProposto', 'regione', 'data', 'azioni'];
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly richieste = signal<RichiestaCreazioneGruppoPending[]>([]);
readonly actionError = signal<string | null>(null);
readonly risolvendoId = signal<string | null>(null);
get isAdmin(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes('admin');
}
async ngOnInit(): Promise<void> {
if (this.isAdmin) {
await this.caricaRichieste();
}
}
private async caricaRichieste(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const richieste = await firstValueFrom(this.richiesteApi.getRichieste());
this.richieste.set(richieste);
} catch {
this.loadError.set('Impossibile caricare le richieste di creazione gruppo. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
formattaData(dataIso: string): string {
return new Date(dataIso).toLocaleDateString('it-IT', { day: 'numeric', month: 'long', year: 'numeric' });
}
async approva(richiesta: RichiestaCreazioneGruppoPending): Promise<void> {
const dialogRef = this.dialog.open(ConfirmDialog, {
data: {
titolo: 'Approva richiesta',
messaggio:
`Approvare la richiesta di creazione del gruppo "${richiesta.nomeProposto}"? ` +
`Verrà creata una nuova organizzazione su Keycloak e ${richiesta.email} diventerà capo-gruppo.`,
confermaLabel: 'Approva'
}
});
const confermato = await firstValueFrom(dialogRef.afterClosed());
if (!confermato) {
return;
}
await this.risolvi(richiesta, 'approvata', 'Richiesta approvata');
}
async rifiuta(richiesta: RichiestaCreazioneGruppoPending): Promise<void> {
const dialogRef = this.dialog.open(ConfirmDialog, {
data: {
titolo: 'Rifiuta richiesta',
messaggio: `Rifiutare la richiesta di creazione del gruppo "${richiesta.nomeProposto}"?`,
confermaLabel: 'Rifiuta'
}
});
const confermato = await firstValueFrom(dialogRef.afterClosed());
if (!confermato) {
return;
}
await this.risolvi(richiesta, 'rifiutata', 'Richiesta rifiutata');
}
private async risolvi(
richiesta: RichiestaCreazioneGruppoPending,
esito: 'approvata' | 'rifiutata',
messaggioSuccesso: string
): Promise<void> {
this.actionError.set(null);
this.risolvendoId.set(richiesta.id);
try {
await firstValueFrom(this.richiesteApi.risolvi(richiesta.id, esito));
this.richieste.update((rows) => rows.filter((r) => r.id !== richiesta.id));
this.snackBar.open(messaggioSuccesso, undefined, { duration: 2000 });
} catch (error) {
this.actionError.set(this.messageForActionError(error as HttpErrorResponse));
} finally {
this.risolvendoId.set(null);
}
}
private messageForActionError(error: HttpErrorResponse): string {
if (error.status === 502) {
return "L'assegnazione su Keycloak non è andata a buon fine: la richiesta resta in attesa, riprova.";
}
return 'Impossibile completare la richiesta. Riprova più tardi.';
}
}
@@ -1,8 +1,8 @@
export const environment = { export const environment = {
production: false, production: false,
keycloakBaseUrl: 'http://localhost:8081', keycloakBaseUrl: 'http://localhost:6999',
keycloakRealm: 'scouthub', keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend', keycloakClientId: 'scouthub-frontend',
orgServiceApiBaseUrl: 'http://localhost:8082', orgServiceApiBaseUrl: 'http://localhost:8000',
attivitaAppBaseUrl: 'http://localhost:4200' attivitaFeBaseUrl: 'http://localhost:7001'
}; };
@@ -1,8 +1,8 @@
export const environment = { export const environment = {
production: true, production: true,
keycloakBaseUrl: 'http://localhost:8081', keycloakBaseUrl: '__KEYCLOAK_BASE_URL__',
keycloakRealm: 'scouthub', keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend', keycloakClientId: 'scouthub-frontend',
orgServiceApiBaseUrl: 'http://localhost:8082', orgServiceApiBaseUrl: '__ORG_SERVICE_API_BASE_URL__',
attivitaAppBaseUrl: 'http://localhost:4200' attivitaFeBaseUrl: '__ATTIVITA_FE_BASE_URL__'
}; };
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" href="https://fonts.googleapis.com/css2?family=Caprasimo&family=Figtree:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
+51 -3
View File
@@ -10,13 +10,61 @@ html {
@include mat.theme( @include mat.theme(
( (
color: ( color: (
primary: mat.$azure-palette, primary: mat.$orange-palette,
tertiary: mat.$blue-palette, tertiary: mat.$green-palette,
),
typography: (
plain-family: Figtree,
brand-family: Caprasimo,
), ),
typography: Roboto,
density: 0, density: 0,
) )
); );
// Scouthub brand palette (design system "Scouthub - Home"): overrides the
// generated M3 tokens above with the exact hex values from the design,
// since the predefined mat.$orange-palette/mat.$green-palette only
// approximate the brand colors.
--mat-sys-primary: #c67139;
--mat-sys-on-primary: #f5ead8;
--mat-sys-primary-container: #fff2eb;
--mat-sys-on-primary-container: #643312;
--mat-sys-secondary: #7a8a5e;
--mat-sys-on-secondary: #f5ead8;
--mat-sys-secondary-container: #f0fae1;
--mat-sys-on-secondary-container: #3d472b;
--mat-sys-tertiary: #7a8a5e;
--mat-sys-on-tertiary: #f5ead8;
--mat-sys-tertiary-container: #f0fae1;
--mat-sys-on-tertiary-container: #3d472b;
--mat-sys-error: #8c2f22;
--mat-sys-on-error: #f5ead8;
--mat-sys-error-container: #fbe1dd;
--mat-sys-on-error-container: #8c2f22;
--mat-sys-background: #f5ead8;
--mat-sys-on-background: #201e1d;
--mat-sys-surface: #f5ead8;
--mat-sys-on-surface: #201e1d;
--mat-sys-surface-variant: #ebddc5;
--mat-sys-on-surface-variant: #474238;
--mat-sys-surface-container-lowest: #ffffff;
--mat-sys-surface-container-low: #f9f4ed;
--mat-sys-surface-container: #ebddc5;
--mat-sys-surface-container-high: #e3d3b8;
--mat-sys-surface-container-highest: #dcc7a5;
--mat-sys-surface-dim: #ded0b6;
--mat-sys-surface-bright: #f5ead8;
--mat-sys-outline: #a89a83;
--mat-sys-outline-variant: #d8c9ab;
--mat-sys-scrim: #2e2b25;
--mat-sys-inverse-surface: #2e2b25;
--mat-sys-inverse-on-surface: #f9f4ed;
--mat-sys-inverse-primary: #f0c9a6;
--mat-sys-corner-small: 12px;
--mat-sys-corner-medium: 16px;
--mat-sys-corner-large: 28px;
--mat-sys-corner-extra-large: 32px;
--mat-sys-corner-full: 999px;
} }
body { body {