From 08271b8a15842a756c3a822652d9641a801d864b Mon Sep 17 00:00:00 2001 From: Lorenzo Sanesi Date: Sat, 25 Jul 2026 12:02:03 +0200 Subject: [PATCH] Fix grafica scouthub-home-fe --- scouthub-home-fe/Dockerfile | 9 + scouthub-home-fe/README.md | 9 +- scouthub-home-fe/src/app/app.css | 143 +++++ scouthub-home-fe/src/app/app.html | 53 +- scouthub-home-fe/src/app/app.routes.ts | 26 +- scouthub-home-fe/src/app/app.ts | 88 ++- .../src/app/core/auth/auth.guard.spec.ts | 4 +- .../src/app/core/auth/auth.guard.ts | 2 +- scouthub-home-fe/src/app/core/auth/roles.ts | 7 + .../src/app/crea-gruppo/crea-gruppo.css | 44 +- .../src/app/crea-gruppo/crea-gruppo.html | 51 +- .../src/app/crea-gruppo/crea-gruppo.spec.ts | 45 +- .../src/app/crea-gruppo/crea-gruppo.ts | 11 +- .../approva-richiesta-dialog.html | 44 ++ .../approva-richiesta-dialog.ts | 36 ++ .../gruppi/confirm-dialog/confirm-dialog.html | 31 ++ .../gruppi/confirm-dialog/confirm-dialog.ts | 27 + .../src/app/gruppi/gruppi-api.service.ts | 29 + .../src/app/gruppi/gruppi-list.guard.spec.ts | 62 +++ .../src/app/gruppi/gruppi-list.guard.ts | 29 + .../app/gruppi/gruppi-list/gruppi-list.css | 51 ++ .../app/gruppi/gruppi-list/gruppi-list.html | 35 ++ .../gruppi/gruppi-list/gruppi-list.spec.ts | 60 ++ .../src/app/gruppi/gruppi-list/gruppi-list.ts | 41 ++ .../src/app/gruppi/gruppi.routes.ts | 17 + .../gruppo-dettaglio/gruppo-dettaglio.css | 193 +++++++ .../gruppo-dettaglio/gruppo-dettaglio.html | 283 ++++++++++ .../gruppo-dettaglio/gruppo-dettaglio.spec.ts | 517 ++++++++++++++++++ .../gruppo-dettaglio/gruppo-dettaglio.ts | 370 +++++++++++++ .../src/app/gruppi/inviti-api.service.ts | 24 + .../app/gruppi/link-ingresso-api.service.ts | 19 + .../src/app/gruppi/membri-api.service.ts | 47 ++ .../gruppi/richieste-ingresso-api.service.ts | 52 ++ scouthub-home-fe/src/app/home/home.css | 122 ++++- scouthub-home-fe/src/app/home/home.html | 51 +- scouthub-home-fe/src/app/home/home.spec.ts | 85 ++- scouthub-home-fe/src/app/home/home.ts | 41 +- .../src/app/ingresso/ingresso-api.service.ts | 30 + .../src/app/ingresso/ingresso.css | 117 ++++ .../src/app/ingresso/ingresso.html | 35 ++ .../src/app/ingresso/ingresso.routes.ts | 8 + .../src/app/ingresso/ingresso.spec.ts | 108 ++++ scouthub-home-fe/src/app/ingresso/ingresso.ts | 89 +++ scouthub-home-fe/src/app/inviti/inviti.css | 111 +++- scouthub-home-fe/src/app/inviti/inviti.html | 49 +- scouthub-home-fe/src/app/profilo/profilo.css | 157 ++++++ scouthub-home-fe/src/app/profilo/profilo.html | 95 ++++ .../src/app/profilo/profilo.routes.ts | 11 + .../src/app/profilo/profilo.spec.ts | 198 +++++++ scouthub-home-fe/src/app/profilo/profilo.ts | 146 +++++ .../richieste-creazione-gruppo-api.service.ts | 52 ++ .../richieste-creazione-gruppo.css | 59 ++ .../richieste-creazione-gruppo.html | 58 ++ .../richieste-creazione-gruppo.routes.ts | 11 + .../richieste-creazione-gruppo.spec.ts | 167 ++++++ .../richieste-creazione-gruppo.ts | 127 +++++ .../environments/environment.development.ts | 6 +- .../src/environments/environment.ts | 6 +- scouthub-home-fe/src/index.html | 2 +- scouthub-home-fe/src/material-theme.scss | 54 +- 60 files changed, 4305 insertions(+), 149 deletions(-) create mode 100644 scouthub-home-fe/src/app/core/auth/roles.ts create mode 100644 scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.html create mode 100644 scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.ts create mode 100644 scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.html create mode 100644 scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi-api.service.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi-list.guard.spec.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi-list.guard.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppi.routes.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.css create mode 100644 scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html create mode 100644 scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts create mode 100644 scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.ts create mode 100644 scouthub-home-fe/src/app/gruppi/inviti-api.service.ts create mode 100644 scouthub-home-fe/src/app/gruppi/link-ingresso-api.service.ts create mode 100644 scouthub-home-fe/src/app/gruppi/membri-api.service.ts create mode 100644 scouthub-home-fe/src/app/gruppi/richieste-ingresso-api.service.ts create mode 100644 scouthub-home-fe/src/app/ingresso/ingresso-api.service.ts create mode 100644 scouthub-home-fe/src/app/ingresso/ingresso.css create mode 100644 scouthub-home-fe/src/app/ingresso/ingresso.html create mode 100644 scouthub-home-fe/src/app/ingresso/ingresso.routes.ts create mode 100644 scouthub-home-fe/src/app/ingresso/ingresso.spec.ts create mode 100644 scouthub-home-fe/src/app/ingresso/ingresso.ts create mode 100644 scouthub-home-fe/src/app/profilo/profilo.css create mode 100644 scouthub-home-fe/src/app/profilo/profilo.html create mode 100644 scouthub-home-fe/src/app/profilo/profilo.routes.ts create mode 100644 scouthub-home-fe/src/app/profilo/profilo.spec.ts create mode 100644 scouthub-home-fe/src/app/profilo/profilo.ts create mode 100644 scouthub-home-fe/src/app/profilo/richieste-creazione-gruppo-api.service.ts create mode 100644 scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.css create mode 100644 scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.html create mode 100644 scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.routes.ts create mode 100644 scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts create mode 100644 scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.ts diff --git a/scouthub-home-fe/Dockerfile b/scouthub-home-fe/Dockerfile index 25ae9b6..9c8c34b 100644 --- a/scouthub-home-fe/Dockerfile +++ b/scouthub-home-fe/Dockerfile @@ -1,11 +1,20 @@ FROM node:22-bookworm-slim AS build +ARG KEYCLOAK_BASE_URL +ARG ORG_SERVICE_API_BASE_URL +ARG ATTIVITA_FE_BASE_URL + WORKDIR /app COPY package*.json ./ RUN npm ci 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 FROM nginx:1.27-alpine diff --git a/scouthub-home-fe/README.md b/scouthub-home-fe/README.md index 8050616..9d233ae 100644 --- a/scouthub-home-fe/README.md +++ b/scouthub-home-fe/README.md @@ -100,10 +100,11 @@ espongono: Il realm `scouthub` viene importato automaticamente da `keycloak/realm-export.json` al primo avvio (`--import-realm`). 2. Il client pubblico `scouthub-frontend` ha `redirectUris`/`webOrigins` che includono - sia `http://localhost:4200` (usato da `scouthub-attivita-fe`) sia - `http://localhost:4201` (porta di default di questo progetto, vedi sotto) — se si - cambia porta, aggiornare `keycloak/realm-export.json` di conseguenza e reimportare - il realm (o aggiornarlo da console admin Keycloak). + `http://localhost:4200` (usato da `scouthub-attivita-fe`), `http://localhost:4201` + (porta di default di questo progetto, vedi sotto) e le porte di + `scouthub-magazzino-fe`/`scouthub-eventi-fe` — il client è ora condiviso da tutti + 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 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 diff --git a/scouthub-home-fe/src/app/app.css b/scouthub-home-fe/src/app/app.css index e69de29..4317acd 100644 --- a/scouthub-home-fe/src/app/app.css +++ b/scouthub-home-fe/src/app/app.css @@ -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); +} diff --git a/scouthub-home-fe/src/app/app.html b/scouthub-home-fe/src/app/app.html index 67e7bd4..2fa7084 100644 --- a/scouthub-home-fe/src/app/app.html +++ b/scouthub-home-fe/src/app/app.html @@ -1 +1,52 @@ - +@if (isStandalonePage) { + +} @else { +
+ + + + Scouthub + + + + + @if (!isAuthenticated) { + + } @else { + + + + + + } + + +
+ + +
+ +
+
+ + +
+} diff --git a/scouthub-home-fe/src/app/app.routes.ts b/scouthub-home-fe/src/app/app.routes.ts index 3324f81..36cad75 100644 --- a/scouthub-home-fe/src/app/app.routes.ts +++ b/scouthub-home-fe/src/app/app.routes.ts @@ -1,20 +1,38 @@ import { Routes } from '@angular/router'; -import { authGuard } from './core/auth/auth.guard'; - export const routes: Routes = [ { + // Pubblica: la home mostra i servizi disponibili anche a chi non è ancora autenticato. path: '', - loadComponent: () => import('./home/home').then((m) => m.Home), - canActivate: [authGuard] + loadComponent: () => import('./home/home').then((m) => m.Home) }, { path: 'crea-gruppo', 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. path: 'inviti', 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) } ]; diff --git a/scouthub-home-fe/src/app/app.ts b/scouthub-home-fe/src/app/app.ts index ce330e2..4e279a7 100644 --- a/scouthub-home-fe/src/app/app.ts +++ b/scouthub-home-fe/src/app/app.ts @@ -1,10 +1,90 @@ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { Component, inject } from '@angular/core'; +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({ selector: 'app-root', - imports: [RouterOutlet], + imports: [RouterOutlet, RouterLink, RouterLinkActive, MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule], templateUrl: './app.html', 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 + '/' }); + } +} diff --git a/scouthub-home-fe/src/app/core/auth/auth.guard.spec.ts b/scouthub-home-fe/src/app/core/auth/auth.guard.spec.ts index c7cbf20..96fa454 100644 --- a/scouthub-home-fe/src/app/core/auth/auth.guard.spec.ts +++ b/scouthub-home-fe/src/app/core/auth/auth.guard.spec.ts @@ -24,13 +24,13 @@ describe('authGuard', () => { 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: {} }); const result = await TestBed.runInInjectionContext(() => authGuard(route, state)); 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 () => { diff --git a/scouthub-home-fe/src/app/core/auth/auth.guard.ts b/scouthub-home-fe/src/app/core/auth/auth.guard.ts index f141f99..2869add 100644 --- a/scouthub-home-fe/src/app/core/auth/auth.guard.ts +++ b/scouthub-home-fe/src/app/core/auth/auth.guard.ts @@ -18,7 +18,7 @@ export async function isAccessAllowed( } const hasOrganization = extractOrganizationFromToken(keycloak.tokenParsed) !== null; - return hasOrganization ? true : router.parseUrl('/crea-gruppo'); + return hasOrganization ? true : router.parseUrl('/profilo'); } export const authGuard: CanActivateFn = createAuthGuard(isAccessAllowed); diff --git a/scouthub-home-fe/src/app/core/auth/roles.ts b/scouthub-home-fe/src/app/core/auth/roles.ts new file mode 100644 index 0000000..930c0eb --- /dev/null +++ b/scouthub-home-fe/src/app/core/auth/roles.ts @@ -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 ?? []; +} diff --git a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.css b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.css index 3e712fa..25296de 100644 --- a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.css +++ b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.css @@ -1,7 +1,30 @@ .crea-gruppo { - max-width: 480px; - margin: 2rem auto; - padding: 0 1rem; + display: flex; + justify-content: center; + 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 { @@ -15,6 +38,19 @@ } .crea-gruppo__error { - color: var(--mat-sys-error, #b3261e); + color: var(--mat-sys-error); 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; } diff --git a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.html b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.html index 45e9da5..71811db 100644 --- a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.html +++ b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.html @@ -1,26 +1,35 @@
-

Crea un nuovo gruppo scout

-

Il tuo account non è ancora collegato a nessun gruppo scout: creane uno per continuare.

+
+ @if (isAdmin) { +

Crea nuovo gruppo

+

Il tuo account non è ancora collegato a nessun gruppo scout: creane uno per continuare.

-
- - Nome del gruppo scout - - @if (nome.hasError('required')) { - Il nome del gruppo è obbligatorio. - } @else if (nome.hasError('minlength')) { - Il nome deve avere almeno 3 caratteri. - } @else if (nome.hasError('maxlength')) { - Il nome non può superare i 100 caratteri. - } - + + + Nome del gruppo scout + + @if (nome.hasError('required')) { + Il nome del gruppo è obbligatorio. + } @else if (nome.hasError('minlength')) { + Il nome deve avere almeno 3 caratteri. + } @else if (nome.hasError('maxlength')) { + Il nome non può superare i 100 caratteri. + } + - @if (errorMessage(); as message) { - + @if (errorMessage(); as message) { + + } + +
+ +
+
+ } @else { +

Accesso non consentito

+

Questa pagina è riservata agli utenti con ruolo admin.

} - - - +
diff --git a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.spec.ts b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.spec.ts index c71dc51..33984e9 100644 --- a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.spec.ts +++ b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.spec.ts @@ -11,13 +11,12 @@ describe('CreaGruppo', () => { let component: CreaGruppo; let fixture: ComponentFixture; let gruppiApi: { creaGruppo: ReturnType }; - let keycloak: { updateToken: ReturnType }; let router: { navigateByUrl: ReturnType }; - beforeEach(async () => { + async function setup(roles: string[] = ['admin']): Promise { gruppiApi = { creaGruppo: vi.fn() }; - keycloak = { updateToken: vi.fn().mockResolvedValue(true) }; router = { navigateByUrl: vi.fn().mockResolvedValue(true) }; + const keycloak = { tokenParsed: { realm_access: { roles } } }; await TestBed.configureTestingModule({ imports: [CreaGruppo], @@ -31,13 +30,18 @@ describe('CreaGruppo', () => { fixture = TestBed.createComponent(CreaGruppo); component = fixture.componentInstance; await fixture.whenStable(); - }); + } - it('si crea correttamente', () => { + it('si crea correttamente', async () => { + await setup(); expect(component).toBeTruthy(); }); describe('validazione del campo nome', () => { + beforeEach(async () => { + await setup(); + }); + it('è invalido se vuoto (required)', () => { component.nome.setValue(''); expect(component.nome.hasError('required')).toBe(true); @@ -79,6 +83,10 @@ describe('CreaGruppo', () => { }); 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 () => { component.nome.setValue('Agesci Milano 1'); gruppiApi.creaGruppo.mockReturnValue( @@ -113,17 +121,40 @@ describe('CreaGruppo', () => { }); 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'); gruppiApi.creaGruppo.mockReturnValue(of({ orgId: 'org-1', gruppiCreati: ['Capi'] })); await component.submit(); expect(gruppiApi.creaGruppo).toHaveBeenCalledWith('Agesci Milano 1'); - expect(keycloak.updateToken).toHaveBeenCalledWith(-1); expect(router.navigateByUrl).toHaveBeenCalledWith('/'); expect(component.submitting()).toBe(false); expect(component.errorMessage()).toBeNull(); }); }); + + 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(); + }); + }); }); diff --git a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.ts b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.ts index 8a2ad1e..a87fb57 100644 --- a/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.ts +++ b/scouthub-home-fe/src/app/crea-gruppo/crea-gruppo.ts @@ -30,7 +30,15 @@ export class CreaGruppo { readonly submitting = signal(false); readonly errorMessage = signal(null); + get isAdmin(): boolean { + return (this.keycloak.tokenParsed?.realm_access?.roles ?? []).includes('admin'); + } + async submit(): Promise { + if (!this.isAdmin) { + return; + } + if (this.submitting()) { return; } @@ -45,9 +53,6 @@ export class CreaGruppo { try { await firstValueFrom(this.gruppiApi.creaGruppo(this.nome.value.trim())); - // Il gruppo è appena stato creato su Keycloak: il token corrente non contiene ancora - // il claim "organization" aggiornato, va quindi forzato un refresh prima di navigare. - await this.keycloak.updateToken(-1).catch(() => undefined); this.submitting.set(false); await this.router.navigateByUrl('/'); } catch (error) { diff --git a/scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.html b/scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.html new file mode 100644 index 0000000..51dab8c --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.html @@ -0,0 +1,44 @@ + + +

Approva richiesta

+ +

Assegna un ruolo a {{ data.email }} per completare l'approvazione.

+ + + Ruolo + + @for (ruolo of data.ruoliSelezionabili; track ruolo) { + {{ ruolo }} + } + + @if (ruoloControl.hasError('required')) { + Seleziona un ruolo. + } + +
+ + + + diff --git a/scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.ts b/scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.ts new file mode 100644 index 0000000..ea18279 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/approva-richiesta-dialog/approva-richiesta-dialog.ts @@ -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(MAT_DIALOG_DATA); + private readonly dialogRef = inject(MatDialogRef); + + 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); + } +} diff --git a/scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.html b/scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.html new file mode 100644 index 0000000..d9374c3 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.html @@ -0,0 +1,31 @@ + + +

{{ data.titolo }}

+{{ data.messaggio }} + + + + diff --git a/scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.ts b/scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.ts new file mode 100644 index 0000000..802264b --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/confirm-dialog/confirm-dialog.ts @@ -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(MAT_DIALOG_DATA); + private readonly dialogRef = inject(MatDialogRef); + + conferma(): void { + this.dialogRef.close(true); + } + + annulla(): void { + this.dialogRef.close(false); + } +} diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-api.service.ts b/scouthub-home-fe/src/app/gruppi/gruppi-api.service.ts new file mode 100644 index 0000000..1ea2100 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi-api.service.ts @@ -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 { + return this.http.get(`${environment.orgServiceApiBaseUrl}/gruppi`); + } + + listGruppiPubblico(): Observable { + return this.http.get(`${environment.orgServiceApiBaseUrl}/gruppi/elenco-pubblico`); + } +} diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list.guard.spec.ts b/scouthub-home-fe/src/app/gruppi/gruppi-list.guard.spec.ts new file mode 100644 index 0000000..4ae933d --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list.guard.spec.ts @@ -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 } = { 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()); + }); +}); diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list.guard.ts b/scouthub-home-fe/src/app/gruppi/gruppi-list.guard.ts new file mode 100644 index 0000000..094500d --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list.guard.ts @@ -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'); +}; diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css new file mode 100644 index 0000000..2cbbf95 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css @@ -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; +} diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html new file mode 100644 index 0000000..77bb449 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html @@ -0,0 +1,35 @@ +
+
+

Tutti i gruppi

+ +
+ + @if (loading()) { +

Caricamento gruppi…

+ } @else if (loadError(); as message) { + + } @else if (gruppi().length === 0) { +

Nessun gruppo scout censito.

+ } @else { +
+ + + + + + + + + + + + + +
Nome + {{ gruppo.nome }} + Regione{{ gruppo.regione ?? '—' }}
+
+ } +
diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts new file mode 100644 index 0000000..e6c4dad --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts @@ -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; + let gruppiApi: { listGruppi: ReturnType }; + + async function setup(): Promise { + 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); + }); +}); diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts new file mode 100644 index 0000000..6339d86 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts @@ -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(null); + readonly gruppi = signal([]); + + async ngOnInit(): Promise { + await this.caricaGruppi(); + } + + private async caricaGruppi(): Promise { + 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); + } + } +} diff --git a/scouthub-home-fe/src/app/gruppi/gruppi.routes.ts b/scouthub-home-fe/src/app/gruppi/gruppi.routes.ts new file mode 100644 index 0000000..b2094d9 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppi.routes.ts @@ -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] + } +]; diff --git a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.css b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.css new file mode 100644 index 0000000..be74ae4 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.css @@ -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; +} diff --git a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html new file mode 100644 index 0000000..54802c7 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html @@ -0,0 +1,283 @@ +
+

Gruppo scout

+ + + + + + + +
+ + @if (canManageMembri) { + + + + } +
+ + +
+
+ @if (loading()) { +

Caricamento membri…

+ } @else if (loadError(); as message) { + + } @else { + @if (actionError(); as message) { + + } + +
+ + + + + + + + + + + + + + + + + + + + + + + +
Email{{ row.email }}Ruolo + @if (canManageMembri) { + + + @for (ruolo of ruoliSelezionabili; track ruolo) { + {{ ruolo }} + } + + + @if (row.ruoloControl.value !== row.ruolo) { + + } + } @else { + {{ row.ruolo ?? '—' }} + } + Gruppo interno{{ row.gruppoInterno ?? '—' }} + @if (canManageMembri) { + + } +
+
+ } +
+ + @if (isAdmin) { +
+

Aggiungi membro già registrato

+ +
+ + Email + + @if (emailControl.hasError('required')) { + L'email è obbligatoria. + } @else if (emailControl.hasError('email')) { + Inserisci un indirizzo email valido. + } + + + + Ruolo + + @for (ruolo of ruoliSelezionabili; track ruolo) { + {{ ruolo }} + } + + @if (ruoloControl.hasError('required')) { + Seleziona un ruolo. + } + + + @if (addMembroError(); as message) { + + } + + +
+
+ } +
+ + + @if (canManageMembri) { +
+
+

+ 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. +

+ +
+ + Email + + @if (invitoEmailControl.hasError('required')) { + L'email è obbligatoria. + } @else if (invitoEmailControl.hasError('email')) { + Inserisci un indirizzo email valido. + } + + + + Ruolo + + @for (ruolo of ruoliSelezionabili; track ruolo) { + {{ ruolo }} + } + + @if (invitoRuoloControl.hasError('required')) { + Seleziona un ruolo. + } + + + @if (invitoError(); as message) { + + } + + +
+ + @if (invitoCreato()) { +

+ Invito creato, scade il {{ scadenzaInvitoFormattata }}. +

+ } +
+
+ } + + + @if (canManageMembri) { + + } + + + @if (canManageMembri) { +
+
+ @if (richiesteLoading()) { +

Caricamento richieste…

+ } @else if (richiesteLoadError(); as message) { + + } @else { + @if (richiesteActionError(); as message) { + + } + + @if (richiesteIngresso().length === 0) { +

Nessuna richiesta in sospeso

+ } @else { +
+ @for (richiesta of richiesteIngresso(); track richiesta.id) { +
+
+ +
+ {{ origineLabel(richiesta.origine) }} · richiesta del {{ formattaDataRichiesta(richiesta.createdAt) }} +
+
+
+ + +
+
+ } +
+ } + } +
+
+ } +
diff --git a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts new file mode 100644 index 0000000..d8ba82e --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts @@ -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; + let membriApi: { + getMembri: ReturnType; + cambiaRuolo: ReturnType; + rimuovi: ReturnType; + aggiungi: ReturnType; + }; + let invitiApi: { creaInvito: ReturnType }; + let linkIngressoApi: { creaOTrovaLink: ReturnType }; + let richiesteIngressoApi: { getRichieste: ReturnType; risolvi: ReturnType }; + let snackBar: { open: ReturnType }; + let dialog: { open: ReturnType }; + + 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; + membri?: Membro[]; + richieste?: RichiestaIngressoPending[]; + }): Promise { + 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); + }); + }); +}); diff --git a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.ts b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.ts new file mode 100644 index 0000000..6ac7dc3 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.ts @@ -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; +} + +@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(null); + readonly membri = signal([]); + readonly actionError = signal(null); + + readonly ruoloSalvandoUserId = signal(null); + readonly rimuovendoUserId = signal(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(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(null); + readonly invitoCreato = signal(null); + + readonly generandoLinkIngresso = signal(false); + readonly linkIngressoError = signal(null); + readonly linkIngresso = signal(null); + + readonly displayedColumnsRichieste = ['email', 'origine', 'data', 'azioni']; + readonly richiesteLoading = signal(true); + readonly richiesteLoadError = signal(null); + readonly richiesteIngresso = signal([]); + readonly richiesteActionError = signal(null); + readonly risolvendoRichiestaId = signal(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 { + const caricamenti: Promise[] = [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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const link = this.linkIngresso(); + if (!link) { + return; + } + + await navigator.clipboard.writeText(link.url); + this.snackBar.open('Copiato', undefined, { duration: 2000 }); + } + + private async caricaRichieste(): Promise { + 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 { + 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 { + 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.'; + } +} diff --git a/scouthub-home-fe/src/app/gruppi/inviti-api.service.ts b/scouthub-home-fe/src/app/gruppi/inviti-api.service.ts new file mode 100644 index 0000000..e8ba899 --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/inviti-api.service.ts @@ -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 { + return this.http.post(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/inviti`, input); + } +} diff --git a/scouthub-home-fe/src/app/gruppi/link-ingresso-api.service.ts b/scouthub-home-fe/src/app/gruppi/link-ingresso-api.service.ts new file mode 100644 index 0000000..af356ad --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/link-ingresso-api.service.ts @@ -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 { + return this.http.post(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/link-ingresso`, {}); + } +} diff --git a/scouthub-home-fe/src/app/gruppi/membri-api.service.ts b/scouthub-home-fe/src/app/gruppi/membri-api.service.ts new file mode 100644 index 0000000..c4b4e6b --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/membri-api.service.ts @@ -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 { + return this.http.get(`${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 { + return this.http.delete(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/membri/${userId}`); + } + + aggiungi(orgId: string, input: AggiungiMembroInput): Observable { + return this.http.post(`${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/membri`, input); + } +} diff --git a/scouthub-home-fe/src/app/gruppi/richieste-ingresso-api.service.ts b/scouthub-home-fe/src/app/gruppi/richieste-ingresso-api.service.ts new file mode 100644 index 0000000..87d827a --- /dev/null +++ b/scouthub-home-fe/src/app/gruppi/richieste-ingresso-api.service.ts @@ -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 { + return this.http.get( + `${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/richieste-ingresso` + ); + } + + richiediIngresso(orgId: string): Observable { + return this.http.post( + `${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/richieste-ingresso`, + {} + ); + } + + risolvi(orgId: string, id: string, input: RisolviRichiestaInput): Observable { + return this.http.put( + `${environment.orgServiceApiBaseUrl}/gruppi/${orgId}/richieste-ingresso/${id}`, + input + ); + } +} diff --git a/scouthub-home-fe/src/app/home/home.css b/scouthub-home-fe/src/app/home/home.css index 4dc6fbb..b4c5dd2 100644 --- a/scouthub-home-fe/src/app/home/home.css +++ b/scouthub-home-fe/src/app/home/home.css @@ -1,39 +1,117 @@ -.dashboard { - display: flex; - flex-direction: column; - gap: 1.5rem; - padding: 2rem; - max-width: 640px; +.home { + max-width: 940px; margin: 0 auto; } -.servizi { - display: flex; - flex-direction: column; - gap: 0.75rem; +.home__title { + font-family: var(--mat-sys-headline-large-font, Caprasimo, sans-serif); + font-size: 30px; + 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 { + 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; flex-direction: column; - gap: 0.25rem; - padding: 1rem 1.25rem; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 0.5rem; + gap: 10px; text-decoration: none; color: inherit; - transition: box-shadow 0.2s ease; } -.servizio-card:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); +.servizio-card__icon { + 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 { - font-weight: 600; +.servizio-card__title { + font-family: var(--mat-sys-title-medium-font, Caprasimo, sans-serif); + font-size: 17px; + margin: 0; } -.servizio-card__descrizione { - font-size: 0.875rem; - opacity: 0.75; +.servizio-card__desc { + margin: 0; + 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); } diff --git a/scouthub-home-fe/src/app/home/home.html b/scouthub-home-fe/src/app/home/home.html index fb0ad6f..6c389b2 100644 --- a/scouthub-home-fe/src/app/home/home.html +++ b/scouthub-home-fe/src/app/home/home.html @@ -1,13 +1,44 @@ -
-

{{ organizationName() ?? 'Il tuo gruppo scout' }}

+
+

Benvenuto in Scouthub

-
-

Servizi

- - Attività - Catalogo e ricerca delle attività scout - -
+ @if (!isAuthenticated) { +

I servizi del tuo gruppo scout, in un unico posto.

+ } @else { +

Ecco i servizi a tua disposizione.

+ } - +
+ @for (servizio of servizi; track servizio.id) { +
+ + +

{{ servizio.nome }}

+

{{ servizio.descrizione }}

+ Apri +
+ + @if (!isAuthenticated && servizio.messaggioAccesso) { +
+ {{ servizio.messaggioAccesso }} + +
+ } +
+ } +
+ + @if (isAdmin) { +
+

Amministrazione

+ +
+ }
diff --git a/scouthub-home-fe/src/app/home/home.spec.ts b/scouthub-home-fe/src/app/home/home.spec.ts index 668a25c..5dfd7a9 100644 --- a/scouthub-home-fe/src/app/home/home.spec.ts +++ b/scouthub-home-fe/src/app/home/home.spec.ts @@ -1,24 +1,23 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import Keycloak from 'keycloak-js'; -import { Observable, of } from 'rxjs'; import { environment } from '../../environments/environment'; -import { OrganizationContextService } from '../core/organization-context.service'; import { Home } from './home'; describe('Home', () => { let component: Home; let fixture: ComponentFixture; - let organizationContext: { currentOrganizationName: Observable }; - let keycloak: { logout: ReturnType }; + let keycloak: { + authenticated: boolean | undefined; + login: ReturnType; + tokenParsed?: { realm_access?: { roles?: string[] } }; + }; async function setup(): Promise { await TestBed.configureTestingModule({ imports: [Home], - providers: [ - { provide: OrganizationContextService, useValue: organizationContext }, - { provide: Keycloak, useValue: keycloak } - ] + providers: [provideRouter([]), { provide: Keycloak, useValue: keycloak }] }).compileComponents(); fixture = TestBed.createComponent(Home); @@ -27,39 +26,73 @@ describe('Home', () => { await fixture.whenStable(); } - beforeEach(() => { - organizationContext = { currentOrganizationName: of('Agesci Milano 1') }; - keycloak = { logout: vi.fn() }; - }); - it('si crea correttamente', async () => { + keycloak = { authenticated: false, login: vi.fn() }; await setup(); + 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(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Agesci Milano 1'); - }); - - it('mostra il link verso l\'app Attività puntando a attivitaAppBaseUrl', async () => { - await setup(); - - const compiled = fixture.nativeElement as HTMLElement; - const link = compiled.querySelector('a.servizio-card') as HTMLAnchorElement; + const link = compiled.querySelector('a.servizio-card__link') as HTMLAnchorElement; 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(); 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(); - 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'); + }); }); }); diff --git a/scouthub-home-fe/src/app/home/home.ts b/scouthub-home-fe/src/app/home/home.ts index 96763d3..f841c6d 100644 --- a/scouthub-home-fe/src/app/home/home.ts +++ b/scouthub-home-fe/src/app/home/home.ts @@ -1,25 +1,50 @@ import { Component, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { RouterLink } from '@angular/router'; import Keycloak from 'keycloak-js'; 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({ selector: 'app-home', - imports: [MatButtonModule], + imports: [MatButtonModule, MatCardModule, RouterLink], templateUrl: './home.html', styleUrl: './home.css' }) export class Home { - private readonly organizationContext = inject(OrganizationContextService); private readonly keycloak = inject(Keycloak); - readonly organizationName = toSignal(this.organizationContext.currentOrganizationName, { initialValue: null }); - readonly attivitaAppBaseUrl = environment.attivitaAppBaseUrl; + readonly servizi = SERVIZI; - logout(): void { - this.keycloak.logout({ redirectUri: window.location.origin + '/' }); + get isAuthenticated(): boolean { + return this.keycloak.authenticated ?? false; + } + + get isAdmin(): boolean { + return extractRealmRoles(this.keycloak.tokenParsed).includes('admin'); + } + + login(): void { + this.keycloak.login({ redirectUri: window.location.href }); } } diff --git a/scouthub-home-fe/src/app/ingresso/ingresso-api.service.ts b/scouthub-home-fe/src/app/ingresso/ingresso-api.service.ts new file mode 100644 index 0000000..5027738 --- /dev/null +++ b/scouthub-home-fe/src/app/ingresso/ingresso-api.service.ts @@ -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 { + return this.http.get(`${environment.orgServiceApiBaseUrl}/link-ingresso/${token}`); + } + + richiediIngresso(token: string): Observable { + return this.http.post( + `${environment.orgServiceApiBaseUrl}/link-ingresso/${token}/richiedi`, + {} + ); + } +} diff --git a/scouthub-home-fe/src/app/ingresso/ingresso.css b/scouthub-home-fe/src/app/ingresso/ingresso.css new file mode 100644 index 0000000..35a41a2 --- /dev/null +++ b/scouthub-home-fe/src/app/ingresso/ingresso.css @@ -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; +} diff --git a/scouthub-home-fe/src/app/ingresso/ingresso.html b/scouthub-home-fe/src/app/ingresso/ingresso.html new file mode 100644 index 0000000..ff6f7f7 --- /dev/null +++ b/scouthub-home-fe/src/app/ingresso/ingresso.html @@ -0,0 +1,35 @@ +
+ + + +
+
+ + Scouthub +
+ + @if (loading()) { +

Caricamento…

+ } @else if (loadError(); as message) { + + } @else if (link(); as linkData) { +

Richiedi di entrare

+

+ Chiedi di entrare nel gruppo {{ linkData.nomeGruppo }}: la + richiesta dovrà essere approvata da un responsabile del gruppo. +

+ + @if (richiestaInviata()) { +

Richiesta inviata, in attesa di approvazione.

+ } @else { + @if (richiestaError(); as errMessage) { + + } + + + } + } +
+
diff --git a/scouthub-home-fe/src/app/ingresso/ingresso.routes.ts b/scouthub-home-fe/src/app/ingresso/ingresso.routes.ts new file mode 100644 index 0000000..19ed9e4 --- /dev/null +++ b/scouthub-home-fe/src/app/ingresso/ingresso.routes.ts @@ -0,0 +1,8 @@ +import { Routes } from '@angular/router'; + +export const INGRESSO_ROUTES: Routes = [ + { + path: ':token', + loadComponent: () => import('./ingresso').then((m) => m.Ingresso) + } +]; diff --git a/scouthub-home-fe/src/app/ingresso/ingresso.spec.ts b/scouthub-home-fe/src/app/ingresso/ingresso.spec.ts new file mode 100644 index 0000000..a033af4 --- /dev/null +++ b/scouthub-home-fe/src/app/ingresso/ingresso.spec.ts @@ -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; + let ingressoApi: { getLinkIngresso: ReturnType; richiediIngresso: ReturnType }; + let keycloak: { authenticated: boolean; login: ReturnType }; + + const linkValido: LinkIngressoPubblico = { nomeGruppo: 'Agesci Milano 1', gruppoId: 'org-1' }; + + async function setup(): Promise { + 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.'); + }); +}); diff --git a/scouthub-home-fe/src/app/ingresso/ingresso.ts b/scouthub-home-fe/src/app/ingresso/ingresso.ts new file mode 100644 index 0000000..b8ce3b8 --- /dev/null +++ b/scouthub-home-fe/src/app/ingresso/ingresso.ts @@ -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(null); + readonly link = signal(null); + + readonly richiedendo = signal(false); + readonly richiestaError = signal(null); + readonly richiestaInviata = signal(false); + + get isAuthenticated(): boolean { + return this.keycloak.authenticated ?? false; + } + + async ngOnInit(): Promise { + await this.caricaLink(); + } + + private async caricaLink(): Promise { + 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 { + 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.'; + } +} diff --git a/scouthub-home-fe/src/app/inviti/inviti.css b/scouthub-home-fe/src/app/inviti/inviti.css index ed6ba42..e0132b0 100644 --- a/scouthub-home-fe/src/app/inviti/inviti.css +++ b/scouthub-home-fe/src/app/inviti/inviti.css @@ -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 { - max-width: 480px; - margin: 2rem auto; - padding: 0 1rem; + min-height: 100vh; + position: relative; + 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 { - color: var(--mat-sys-error, #b3261e); + color: var(--mat-sys-error); + font-size: 14px; } diff --git a/scouthub-home-fe/src/app/inviti/inviti.html b/scouthub-home-fe/src/app/inviti/inviti.html index f535c71..015e794 100644 --- a/scouthub-home-fe/src/app/inviti/inviti.html +++ b/scouthub-home-fe/src/app/inviti/inviti.html @@ -1,24 +1,37 @@
- @if (loading()) { -

Caricamento invito…

- } @else if (loadError(); as message) { - - } @else if (invito(); as invitoData) { -

Invito al gruppo scout {{ invitoData.nomeGruppo }}

-

Ruolo offerto: {{ invitoData.ruolo }}

+ + - @if (!invitoData.valido) { -
+
+ + Scouthub +
+ + @if (loading()) { +

Caricamento invito…

+ } @else if (loadError(); as message) { + + } @else if (invito(); as invitoData) { +

Sei stato invitato

+

+ Unisciti al gruppo {{ invitoData.nomeGruppo }} su Scouthub come + {{ invitoData.ruolo }}.

- } @else { - @if (acceptError(); as errMessage) { - - } - + @if (!invitoData.valido) { + + } @else { + @if (acceptError(); as errMessage) { + + } + + + } } - } +
diff --git a/scouthub-home-fe/src/app/profilo/profilo.css b/scouthub-home-fe/src/app/profilo/profilo.css new file mode 100644 index 0000000..05935c0 --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/profilo.css @@ -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; +} diff --git a/scouthub-home-fe/src/app/profilo/profilo.html b/scouthub-home-fe/src/app/profilo/profilo.html new file mode 100644 index 0000000..d591726 --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/profilo.html @@ -0,0 +1,95 @@ +
+

Il tuo profilo

+ + @if (hasOrganization()) { +
+
+ {{ (organizationName() ?? 'Gruppo scout')[0] }} +
+
Gruppo
+
{{ organizationName() ?? 'Gruppo scout' }}
+ + {{ ruoloCorrente ?? 'Ruolo non disponibile' }} + +
+ } @else { +
+
+ Percorso 1 +

Entra in un gruppo esistente

+

Cerca il tuo gruppo scout e richiedi di entrare a farne parte.

+ + @if (caricandoGruppi()) { +

Caricamento gruppi…

+ } @else if (gruppiDisponibili().length === 0) { +

Nessun gruppo scout disponibile al momento.

+ } @else { +
+ + Gruppo scout + + @for (gruppo of gruppiDisponibili(); track gruppo.orgId) { + {{ gruppo.nome }} + } + + @if (gruppoSelezionatoControl.hasError('required')) { + Seleziona un gruppo. + } + + + @if (richiestaIngressoError(); as message) { + + } + + @if (richiestaIngressoInviata()) { +

Richiesta inviata, in attesa di approvazione.

+ } + + +
+ } +
+ +
oppure
+ +
+ Percorso 2 +

Non trovi il tuo gruppo?

+

Richiedi all'amministratore centrale la creazione di un nuovo gruppo.

+ +
+ + Nome proposto + + @if (nomePropostoControl.hasError('required')) { + Il nome proposto è obbligatorio. + } + + + + Regione (opzionale) + + + + @if (richiestaCreazioneError(); as message) { + + } + + @if (richiestaCreazioneInviata()) { +

Richiesta inviata, in attesa di approvazione.

+ } + + +
+
+
+ } +
diff --git a/scouthub-home-fe/src/app/profilo/profilo.routes.ts b/scouthub-home-fe/src/app/profilo/profilo.routes.ts new file mode 100644 index 0000000..8724c43 --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/profilo.routes.ts @@ -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] + } +]; diff --git a/scouthub-home-fe/src/app/profilo/profilo.spec.ts b/scouthub-home-fe/src/app/profilo/profilo.spec.ts new file mode 100644 index 0000000..baea1bd --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/profilo.spec.ts @@ -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; + let gruppiApi: { listGruppiPubblico: ReturnType }; + let richiesteIngressoApi: { richiediIngresso: ReturnType }; + let richiesteCreazioneApi: { creaRichiesta: ReturnType }; + + async function setup(options: { + roles: string[]; + hasOrganization: Observable; + currentOrganizationName?: Observable; + gruppiDisponibili?: Observable<{ orgId: string; nome: string }[]>; + }): Promise { + 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); + }); + }); + }); +}); diff --git a/scouthub-home-fe/src/app/profilo/profilo.ts b/scouthub-home-fe/src/app/profilo/profilo.ts new file mode 100644 index 0000000..54adaae --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/profilo.ts @@ -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([]); + readonly caricandoGruppi = signal(true); + + readonly gruppoSelezionatoControl = new FormControl(null, { validators: [Validators.required] }); + readonly richiedendoIngresso = signal(false); + readonly richiestaIngressoError = signal(null); + readonly richiestaIngressoInviata = signal(false); + + constructor() { + this.caricaGruppiDisponibili(); + } + + private async caricaGruppiDisponibili(): Promise { + 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(null); + readonly richiestaCreazioneInviata = signal(false); + + async richiediIngresso(): Promise { + 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 { + 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.'; + } +} diff --git a/scouthub-home-fe/src/app/profilo/richieste-creazione-gruppo-api.service.ts b/scouthub-home-fe/src/app/profilo/richieste-creazione-gruppo-api.service.ts new file mode 100644 index 0000000..fea2120 --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/richieste-creazione-gruppo-api.service.ts @@ -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 { + return this.http.post( + `${environment.orgServiceApiBaseUrl}/richieste-creazione-gruppo`, + input + ); + } + + getRichieste(): Observable { + return this.http.get( + `${environment.orgServiceApiBaseUrl}/richieste-creazione-gruppo` + ); + } + + risolvi(id: string, esito: 'approvata' | 'rifiutata'): Observable { + return this.http.put( + `${environment.orgServiceApiBaseUrl}/richieste-creazione-gruppo/${id}`, + { esito } + ); + } +} diff --git a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.css b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.css new file mode 100644 index 0000000..98dce08 --- /dev/null +++ b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.css @@ -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; +} diff --git a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.html b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.html new file mode 100644 index 0000000..59556fb --- /dev/null +++ b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.html @@ -0,0 +1,58 @@ +
+ @if (isAdmin) { +

Richieste di creazione gruppo

+ + @if (loading()) { +

Caricamento richieste…

+ } @else if (loadError(); as message) { + + } @else { + @if (actionError(); as message) { + + } + + @if (richieste().length === 0) { +

Nessuna richiesta in sospeso

+ } @else { +
+ @for (richiesta of richieste(); track richiesta.id) { +
+
+
{{ richiesta.nomeProposto }}
+
+ {{ richiesta.email }} · {{ richiesta.regione ?? 'regione non indicata' }} · richiesta del + {{ formattaData(richiesta.createdAt) }} +
+
+
+ + +
+
+ } +
+ } + } + } @else { +

Accesso non consentito

+

Questa pagina è riservata agli utenti con ruolo admin.

+ } +
diff --git a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.routes.ts b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.routes.ts new file mode 100644 index 0000000..75bd2dd --- /dev/null +++ b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.routes.ts @@ -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] + } +]; diff --git a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts new file mode 100644 index 0000000..f0dd1f4 --- /dev/null +++ b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts @@ -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; + let richiesteApi: { getRichieste: ReturnType; risolvi: ReturnType }; + let dialog: { open: ReturnType }; + let snackBar: { open: ReturnType }; + + 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 { + 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(); + }); + }); +}); diff --git a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.ts b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.ts new file mode 100644 index 0000000..b47ad2d --- /dev/null +++ b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.ts @@ -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(null); + readonly richieste = signal([]); + readonly actionError = signal(null); + readonly risolvendoId = signal(null); + + get isAdmin(): boolean { + return extractRealmRoles(this.keycloak.tokenParsed).includes('admin'); + } + + async ngOnInit(): Promise { + if (this.isAdmin) { + await this.caricaRichieste(); + } + } + + private async caricaRichieste(): Promise { + 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 { + 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 { + 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 { + 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.'; + } +} diff --git a/scouthub-home-fe/src/environments/environment.development.ts b/scouthub-home-fe/src/environments/environment.development.ts index 23b668c..8ad4e2f 100644 --- a/scouthub-home-fe/src/environments/environment.development.ts +++ b/scouthub-home-fe/src/environments/environment.development.ts @@ -1,8 +1,8 @@ export const environment = { production: false, - keycloakBaseUrl: 'http://localhost:8081', + keycloakBaseUrl: 'http://localhost:6999', keycloakRealm: 'scouthub', keycloakClientId: 'scouthub-frontend', - orgServiceApiBaseUrl: 'http://localhost:8082', - attivitaAppBaseUrl: 'http://localhost:4200' + orgServiceApiBaseUrl: 'http://localhost:8000', + attivitaFeBaseUrl: 'http://localhost:7001' }; diff --git a/scouthub-home-fe/src/environments/environment.ts b/scouthub-home-fe/src/environments/environment.ts index e216978..5a44214 100644 --- a/scouthub-home-fe/src/environments/environment.ts +++ b/scouthub-home-fe/src/environments/environment.ts @@ -1,8 +1,8 @@ export const environment = { production: true, - keycloakBaseUrl: 'http://localhost:8081', + keycloakBaseUrl: '__KEYCLOAK_BASE_URL__', keycloakRealm: 'scouthub', keycloakClientId: 'scouthub-frontend', - orgServiceApiBaseUrl: 'http://localhost:8082', - attivitaAppBaseUrl: 'http://localhost:4200' + orgServiceApiBaseUrl: '__ORG_SERVICE_API_BASE_URL__', + attivitaFeBaseUrl: '__ATTIVITA_FE_BASE_URL__' }; diff --git a/scouthub-home-fe/src/index.html b/scouthub-home-fe/src/index.html index 29b7223..3ae6293 100644 --- a/scouthub-home-fe/src/index.html +++ b/scouthub-home-fe/src/index.html @@ -9,7 +9,7 @@ diff --git a/scouthub-home-fe/src/material-theme.scss b/scouthub-home-fe/src/material-theme.scss index 2ccd444..a2b18d9 100644 --- a/scouthub-home-fe/src/material-theme.scss +++ b/scouthub-home-fe/src/material-theme.scss @@ -10,13 +10,61 @@ html { @include mat.theme( ( color: ( - primary: mat.$azure-palette, - tertiary: mat.$blue-palette, + primary: mat.$orange-palette, + tertiary: mat.$green-palette, + ), + typography: ( + plain-family: Figtree, + brand-family: Caprasimo, ), - typography: Roboto, 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 {