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 {
+
+
+
+ S
+ Scouthub
+
+
+
+
+ @if (!isAuthenticated) {
+ Accedi
+ } @else {
+
+ {{ userInitials }}
+ {{ userName }}
+ expand_more
+
+
+ Profilo
+ Esci
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @for (item of navItems; track item.path) {
+
+ {{ item.icon }}
+ {{ item.label }}
+
+ }
+
+
+}
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.
-
+ } @else {
+
Accesso non consentito
+
Questa pagina è riservata agli utenti con ruolo admin.
}
-
-
- {{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
-
-
+
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.
+ }
+
+
+
+ Annulla
+
+ Approva
+
+
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 }}
+
+ Annulla
+
+ {{ data.confermaLabel ?? 'Conferma' }}
+
+
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 @@
+
+
+
+ @if (loading()) {
+ Caricamento gruppi…
+ } @else if (loadError(); as message) {
+ {{ 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
+
+
+
+
+
+
+
+
+ Membri
+ @if (canManageMembri) {
+ Inviti
+ Link d'ingresso
+
+ Richieste
+ @if (richiesteIngresso().length > 0) {
+ ({{ richiesteIngresso().length }})
+ }
+
+ }
+
+
+
+
+
+ @if (loading()) {
+ Caricamento membri…
+ } @else if (loadError(); as message) {
+ {{ message }}
+ } @else {
+ @if (actionError(); as message) {
+ {{ message }}
+ }
+
+
+
+
+ Email
+ {{ row.email }}
+
+
+
+ Ruolo
+
+ @if (canManageMembri) {
+
+
+ @for (ruolo of ruoliSelezionabili; track ruolo) {
+ {{ ruolo }}
+ }
+
+
+ @if (row.ruoloControl.value !== row.ruolo) {
+
+ {{ ruoloSalvandoUserId() === row.userId ? 'Salvataggio…' : 'Conferma' }}
+
+ }
+ } @else {
+ {{ row.ruolo ?? '—' }}
+ }
+
+
+
+
+ Gruppo interno
+ {{ row.gruppoInterno ?? '—' }}
+
+
+
+
+
+ @if (canManageMembri) {
+
+ {{ rimuovendoUserId() === row.userId ? 'Rimozione…' : 'Rimuovi' }}
+
+ }
+
+
+
+
+
+
+
+ }
+
+
+ @if (isAdmin) {
+
+ Aggiungi membro già registrato
+
+
+
+ }
+
+
+
+ @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.
+
+
+
+
+ @if (invitoCreato()) {
+
+ Invito creato, scade il {{ scadenzaInvitoFormattata }}.
+
+ }
+
+
+ }
+
+
+ @if (canManageMembri) {
+
+
+
+ Chi apre questo link può chiedere di entrare nel gruppo; la richiesta dovrà comunque essere approvata.
+
+
+
+ {{ generandoLinkIngresso() ? 'Generazione…' : 'Genera/mostra link di ingresso' }}
+
+
+ @if (linkIngressoError(); as message) {
+ {{ message }}
+ }
+
+ @if (linkIngresso(); as link) {
+
+
+ Link di ingresso
+
+
+ Copia
+
+ }
+
+
+ }
+
+
+ @if (canManageMembri) {
+
+
+ @if (richiesteLoading()) {
+ Caricamento richieste…
+ } @else if (richiesteLoadError(); as message) {
+ {{ message }}
+ } @else {
+ @if (richiesteActionError(); as message) {
+ {{ message }}
+ }
+
+ @if (richiesteIngresso().length === 0) {
+ Nessuna richiesta in sospeso
+ } @else {
+
+ @for (richiesta of richiesteIngresso(); track richiesta.id) {
+
+
+
{{ richiesta.email }}
+
+ {{ 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
-
+ @if (!isAuthenticated) {
+ I servizi del tuo gruppo scout, in un unico posto.
+ } @else {
+ Ecco i servizi a tua disposizione.
+ }
- Esci
+
+ @for (servizio of servizi; track servizio.id) {
+
+ }
+
+
+ @if (isAdmin) {
+
+ }
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 @@
+
+
+
+
+
+
+ S
+ Scouthub
+
+
+ @if (loading()) {
+
Caricamento…
+ } @else if (loadError(); as message) {
+
{{ 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) {
+
{{ errMessage }}
+ }
+
+
+ {{ richiedendo() ? 'Invio in corso…' : (isAuthenticated ? 'Richiedi di entrare' : 'Accedi e richiedi di entrare') }}
+
+ }
+ }
+
+
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) {
- {{ message }}
- } @else if (invito(); as invitoData) {
- Invito al gruppo scout {{ invitoData.nomeGruppo }}
- Ruolo offerto: {{ invitoData.ruolo }}
+
+
- @if (!invitoData.valido) {
-
- Questo invito non è più valido: potrebbe essere scaduto o già stato accettato.
+
+
+ S
+ Scouthub
+
+
+ @if (loading()) {
+
Caricamento invito…
+ } @else if (loadError(); as message) {
+
{{ message }}
+ } @else if (invito(); as invitoData) {
+
Sei stato invitato
+
+ Unisciti al gruppo {{ invitoData.nomeGruppo }} su Scouthub come
+ {{ invitoData.ruolo }} .
- } @else {
- @if (acceptError(); as errMessage) {
-
{{ errMessage }}
- }
-
- {{ accepting() ? 'Accettazione in corso…' : 'Accetta invito' }}
-
+ @if (!invitoData.valido) {
+
+ Questo invito non è più valido: potrebbe essere scaduto o già stato accettato.
+
+ } @else {
+ @if (acceptError(); as errMessage) {
+
{{ errMessage }}
+ }
+
+
+ {{ accepting() ? 'Accettazione in corso…' : (isAuthenticated ? 'Accetta invito' : 'Accetta e crea il tuo account') }}
+
+ }
}
- }
+
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 {
+
+ }
+
+
+
oppure
+
+
+ Percorso 2
+ Non trovi il tuo gruppo?
+ Richiedi all'amministratore centrale la creazione di un nuovo gruppo.
+
+
+
+
+ }
+
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) {
+ {{ message }}
+ } @else {
+ @if (actionError(); as message) {
+ {{ 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 {