Fix grafica scouthub-home-fe

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