Fix grafica scouthub-home-fe
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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,26 +1,35 @@
|
||||
<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">
|
||||
<mat-label>Nome del gruppo scout</mat-label>
|
||||
<input matInput [formControl]="nome" placeholder="Es. Agesci Milano 1" />
|
||||
@if (nome.hasError('required')) {
|
||||
<mat-error>Il nome del gruppo è obbligatorio.</mat-error>
|
||||
} @else if (nome.hasError('minlength')) {
|
||||
<mat-error>Il nome deve avere almeno 3 caratteri.</mat-error>
|
||||
} @else if (nome.hasError('maxlength')) {
|
||||
<mat-error>Il nome non può superare i 100 caratteri.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<form class="crea-gruppo__form" (submit)="$event.preventDefault(); submit()" novalidate>
|
||||
<mat-form-field appearance="outline" class="crea-gruppo__field">
|
||||
<mat-label>Nome del gruppo scout</mat-label>
|
||||
<input matInput [formControl]="nome" placeholder="Es. Agesci Milano 1" />
|
||||
@if (nome.hasError('required')) {
|
||||
<mat-error>Il nome del gruppo è obbligatorio.</mat-error>
|
||||
} @else if (nome.hasError('minlength')) {
|
||||
<mat-error>Il nome deve avere almeno 3 caratteri.</mat-error>
|
||||
} @else if (nome.hasError('maxlength')) {
|
||||
<mat-error>Il nome non può superare i 100 caratteri.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (errorMessage(); as message) {
|
||||
<p class="crea-gruppo__error" role="alert">{{ message }}</p>
|
||||
@if (errorMessage(); as message) {
|
||||
<p class="crea-gruppo__error" role="alert">{{ message }}</p>
|
||||
}
|
||||
|
||||
<div class="crea-gruppo__actions">
|
||||
<button class="crea-gruppo__submit" mat-flat-button type="submit" [disabled]="submitting()">
|
||||
{{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<h1 class="crea-gruppo__title">Accesso non consentito</h1>
|
||||
<p role="alert">Questa pagina è riservata agli utenti con ruolo admin.</p>
|
||||
}
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="submitting()">
|
||||
{{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
|
||||
</button>
|
||||
</form>
|
||||
</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) {
|
||||
|
||||
+44
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</a>
|
||||
</section>
|
||||
@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>
|
||||
}
|
||||
|
||||
<button mat-stroked-button type="button" (click)="logout()">Esci</button>
|
||||
<div class="servizi-grid">
|
||||
@for (servizio of servizi; track servizio.id) {
|
||||
<div class="servizio-card">
|
||||
<a class="servizio-card__link" [href]="servizio.url">
|
||||
<span class="servizio-card__icon" aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h16v16H4z" />
|
||||
<path d="M8 9h8M8 13h8M8 17h5" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="servizio-card__title">{{ servizio.nome }}</h2>
|
||||
<p class="servizio-card__desc">{{ servizio.descrizione }}</p>
|
||||
<span class="servizio-card__cta">Apri</span>
|
||||
</a>
|
||||
|
||||
@if (!isAuthenticated && servizio.messaggioAccesso) {
|
||||
<div class="servizio-card__avviso">
|
||||
<span>{{ servizio.messaggioAccesso }}</span>
|
||||
<button mat-stroked-button type="button" (click)="login()">Accedi</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isAdmin) {
|
||||
<section class="amministrazione">
|
||||
<h2 class="amministrazione__title">Amministrazione</h2>
|
||||
<nav class="amministrazione__links">
|
||||
<a class="amministrazione__link" routerLink="/gruppi">Gruppi scout</a>
|
||||
<a class="amministrazione__link" routerLink="/richieste-creazione-gruppo">Richieste di creazione gruppo</a>
|
||||
</nav>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
<section class="inviti">
|
||||
@if (loading()) {
|
||||
<p>Caricamento invito…</p>
|
||||
} @else if (loadError(); as message) {
|
||||
<p class="inviti__error" role="alert">{{ message }}</p>
|
||||
} @else if (invito(); as invitoData) {
|
||||
<h1>Invito al gruppo scout {{ invitoData.nomeGruppo }}</h1>
|
||||
<p>Ruolo offerto: <strong>{{ invitoData.ruolo }}</strong></p>
|
||||
<div class="inviti__blob inviti__blob--top" aria-hidden="true"></div>
|
||||
<div class="inviti__blob inviti__blob--bottom" aria-hidden="true"></div>
|
||||
|
||||
@if (!invitoData.valido) {
|
||||
<p class="inviti__error" role="alert">
|
||||
Questo invito non è più valido: potrebbe essere scaduto o già stato accettato.
|
||||
<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 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>
|
||||
} @else {
|
||||
@if (acceptError(); as errMessage) {
|
||||
<p class="inviti__error" role="alert">{{ errMessage }}</p>
|
||||
}
|
||||
|
||||
<button mat-flat-button color="primary" (click)="accetta()" [disabled]="accepting()">
|
||||
{{ accepting() ? 'Accettazione in corso…' : 'Accetta invito' }}
|
||||
</button>
|
||||
@if (!invitoData.valido) {
|
||||
<p class="inviti__error" role="alert">
|
||||
Questo invito non è più valido: potrebbe essere scaduto o già stato accettato.
|
||||
</p>
|
||||
} @else {
|
||||
@if (acceptError(); as errMessage) {
|
||||
<p class="inviti__error" role="alert">{{ errMessage }}</p>
|
||||
}
|
||||
|
||||
<button 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
+11
@@ -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]
|
||||
}
|
||||
];
|
||||
+167
@@ -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__'
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user