Sistemato home e keycloak

This commit is contained in:
Lorenzo Sanesi
2026-07-26 00:38:08 +02:00
parent 5855352c8c
commit 8d1a3e0d18
34 changed files with 744 additions and 131 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4201
"port": 7000
},
"configurations": {
"production": {
+5 -2
View File
@@ -33,6 +33,8 @@
.app-shell__brand-name {
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 19px;
line-height: 1;
display: inline-block;
}
.app-shell__spacer {
@@ -47,7 +49,7 @@
border-radius: 999px !important;
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
}
.app-shell__avatar {
@@ -56,7 +58,8 @@
border-radius: 50%;
background: var(--mat-sys-secondary);
color: var(--mat-sys-on-secondary);
display: flex;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
font-size: 11px;
-7
View File
@@ -18,13 +18,6 @@ export const routes: 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',
@@ -49,3 +49,8 @@
.gruppi-list__link:hover {
text-decoration: underline;
}
app-richieste-creazione-gruppo {
display: block;
margin-top: 40px;
}
@@ -32,4 +32,6 @@
</table>
</div>
}
<app-richieste-creazione-gruppo />
</section>
@@ -1,7 +1,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js';
import { of, throwError } from 'rxjs';
import { RichiesteCreazioneGruppoApiService } from '../../profilo/richieste-creazione-gruppo-api.service';
import { GruppiApiService, GruppoListItem } from '../gruppi-api.service';
import { GruppiList } from './gruppi-list';
@@ -13,7 +17,16 @@ describe('GruppiList', () => {
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [GruppiList],
providers: [provideRouter([]), { provide: GruppiApiService, useValue: gruppiApi }]
providers: [
provideRouter([]),
{ provide: GruppiApiService, useValue: gruppiApi },
// GruppiList incorpora <app-richieste-creazione-gruppo>, che richiede le sue
// stesse dipendenze (vedi richieste-creazione-gruppo.spec.ts).
{ provide: RichiesteCreazioneGruppoApiService, useValue: { getRichieste: vi.fn().mockReturnValue(of([])) } },
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: ['admin'] } } } },
{ provide: MatDialog, useValue: { open: vi.fn() } },
{ provide: MatSnackBar, useValue: { open: vi.fn() } }
]
}).compileComponents();
fixture = TestBed.createComponent(GruppiList);
@@ -4,11 +4,12 @@ import { MatTableModule } from '@angular/material/table';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { RichiesteCreazioneGruppo } from '../../richieste-creazione-gruppo/richieste-creazione-gruppo';
import { GruppiApiService, GruppoListItem } from '../gruppi-api.service';
@Component({
selector: 'app-gruppi-list',
imports: [MatButtonModule, MatTableModule, RouterLink],
imports: [MatButtonModule, MatTableModule, RouterLink, RichiesteCreazioneGruppo],
templateUrl: './gruppi-list.html',
styleUrl: './gruppi-list.css'
})
-29
View File
@@ -86,32 +86,3 @@
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);
}
-10
View File
@@ -31,14 +31,4 @@
</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>
@@ -67,32 +67,4 @@ describe('Home', () => {
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 -7
View File
@@ -1,11 +1,9 @@
import { Component, inject } from '@angular/core';
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 { extractRealmRoles } from '../core/auth/roles';
export interface ServizioCard {
id: string;
@@ -41,7 +39,7 @@ const SERVIZI: ServizioCard[] = [
@Component({
selector: 'app-home',
imports: [MatButtonModule, MatCardModule, RouterLink],
imports: [MatButtonModule, MatCardModule],
templateUrl: './home.html',
styleUrl: './home.css'
})
@@ -54,10 +52,6 @@ export class Home {
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,72 @@
.modifica-profilo {
max-width: 520px;
display: flex;
flex-direction: column;
gap: 20px;
}
.modifica-profilo__title {
font-family: var(--mat-sys-headline-medium-font, Caprasimo, sans-serif);
font-size: 28px;
margin: 0;
}
.modifica-profilo__card {
background: var(--mat-sys-surface-container);
border-radius: 32px;
padding: 26px;
display: flex;
flex-direction: column;
gap: 12px;
}
.modifica-profilo__section-title {
font-family: var(--mat-sys-title-medium-font, Caprasimo, sans-serif);
font-size: 17px;
margin: 0;
}
.modifica-profilo__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.modifica-profilo__actions {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
.modifica-profilo__submit {
border-radius: 999px !important;
background: var(--mat-sys-primary) !important;
color: var(--mat-sys-on-primary) !important;
}
.modifica-profilo__submit--secondary {
background: var(--mat-sys-secondary) !important;
color: var(--mat-sys-on-secondary) !important;
}
.modifica-profilo__error {
color: var(--mat-sys-error);
margin: 0;
font-size: 13px;
}
.modifica-profilo__conferma {
color: var(--mat-sys-primary);
margin: 0;
font-size: 13px;
}
.modifica-profilo__hint {
font-size: 0.875rem;
opacity: 0.75;
}
.modifica-profilo__back {
align-self: flex-start;
border-radius: 999px !important;
}
@@ -0,0 +1,97 @@
<section class="modifica-profilo">
<h1 class="modifica-profilo__title">Modifica dati di registrazione</h1>
@if (caricamento()) {
<p class="modifica-profilo__hint">Caricamento…</p>
} @else if (caricamentoError(); as message) {
<p class="modifica-profilo__error" role="alert">{{ message }}</p>
} @else {
<div class="modifica-profilo__card">
<h2 class="modifica-profilo__section-title">Dati personali</h2>
<form class="modifica-profilo__form" (submit)="$event.preventDefault(); salvaProfilo()" novalidate>
<mat-form-field appearance="outline">
<mat-label>Nome</mat-label>
<input matInput [formControl]="nome" />
@if (nome.hasError('required')) {
<mat-error>Il nome è obbligatorio.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cognome</mat-label>
<input matInput [formControl]="cognome" />
@if (cognome.hasError('required')) {
<mat-error>Il cognome è obbligatorio.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" [formControl]="email" />
@if (email.hasError('required')) {
<mat-error>L'email è obbligatoria.</mat-error>
} @else if (email.hasError('email')) {
<mat-error>Inserisci un'email valida.</mat-error>
}
</mat-form-field>
@if (salvataggioError(); as message) {
<p class="modifica-profilo__error" role="alert">{{ message }}</p>
}
@if (salvataggioOk()) {
<p class="modifica-profilo__conferma" role="status">Dati aggiornati con successo.</p>
}
<div class="modifica-profilo__actions">
<button class="modifica-profilo__submit" mat-flat-button type="submit" [disabled]="salvataggio()">
{{ salvataggio() ? 'Salvataggio…' : 'Salva modifiche' }}
</button>
</div>
</form>
</div>
<div class="modifica-profilo__card">
<h2 class="modifica-profilo__section-title">Cambia password</h2>
<form class="modifica-profilo__form" (submit)="$event.preventDefault(); cambiaPassword()" novalidate>
<mat-form-field appearance="outline">
<mat-label>Nuova password</mat-label>
<input matInput type="password" [formControl]="nuovaPassword" />
@if (nuovaPassword.hasError('required')) {
<mat-error>La nuova password è obbligatoria.</mat-error>
} @else if (nuovaPassword.hasError('minlength')) {
<mat-error>La password deve avere almeno 8 caratteri.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Conferma password</mat-label>
<input matInput type="password" [formControl]="confermaPassword" />
@if (confermaPassword.hasError('required')) {
<mat-error>Conferma la nuova password.</mat-error>
}
</mat-form-field>
@if (cambioPasswordError(); as message) {
<p class="modifica-profilo__error" role="alert">{{ message }}</p>
}
@if (cambioPasswordOk()) {
<p class="modifica-profilo__conferma" role="status">Password aggiornata con successo.</p>
}
<div class="modifica-profilo__actions">
<button class="modifica-profilo__submit modifica-profilo__submit--secondary" mat-flat-button type="submit" [disabled]="cambioPassword()">
{{ cambioPassword() ? 'Aggiornamento…' : 'Aggiorna password' }}
</button>
</div>
</form>
</div>
}
<button class="modifica-profilo__back" mat-stroked-button type="button" (click)="tornaAlProfilo()">
Torna al profilo
</button>
</section>
@@ -0,0 +1,132 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { UtenteApiService } from '../utente-api.service';
@Component({
selector: 'app-modifica-profilo',
imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatProgressSpinnerModule],
templateUrl: './modifica-profilo.html',
styleUrl: './modifica-profilo.css'
})
export class ModificaProfilo {
private readonly utenteApi = inject(UtenteApiService);
private readonly router = inject(Router);
readonly caricamento = signal(true);
readonly caricamentoError = signal<string | null>(null);
readonly email = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] });
readonly nome = new FormControl('', { nonNullable: true, validators: [Validators.required] });
readonly cognome = new FormControl('', { nonNullable: true, validators: [Validators.required] });
readonly salvataggio = signal(false);
readonly salvataggioError = signal<string | null>(null);
readonly salvataggioOk = signal(false);
readonly nuovaPassword = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(8)] });
readonly confermaPassword = new FormControl('', { nonNullable: true, validators: [Validators.required] });
readonly cambioPassword = signal(false);
readonly cambioPasswordError = signal<string | null>(null);
readonly cambioPasswordOk = signal(false);
constructor() {
this.caricaProfilo();
}
private async caricaProfilo(): Promise<void> {
try {
const profilo = await firstValueFrom(this.utenteApi.getMe());
this.email.setValue(profilo.email);
this.nome.setValue(profilo.nome);
this.cognome.setValue(profilo.cognome);
} catch {
this.caricamentoError.set('Impossibile caricare i dati del profilo. Riprova più tardi.');
} finally {
this.caricamento.set(false);
}
}
async salvaProfilo(): Promise<void> {
if (this.salvataggio()) {
return;
}
if (this.email.invalid || this.nome.invalid || this.cognome.invalid) {
this.email.markAsTouched();
this.nome.markAsTouched();
this.cognome.markAsTouched();
return;
}
this.salvataggio.set(true);
this.salvataggioError.set(null);
this.salvataggioOk.set(false);
try {
await firstValueFrom(
this.utenteApi.updateMe({
email: this.email.value.trim(),
nome: this.nome.value.trim(),
cognome: this.cognome.value.trim()
})
);
this.salvataggioOk.set(true);
} catch (error) {
this.salvataggioError.set(this.messageForProfiloError(error as HttpErrorResponse));
} finally {
this.salvataggio.set(false);
}
}
private messageForProfiloError(error: HttpErrorResponse): string {
if (error.status === 409) {
return 'Esiste già un utente registrato con questa email.';
}
return 'Impossibile salvare le modifiche. Riprova più tardi.';
}
async cambiaPassword(): Promise<void> {
if (this.cambioPassword()) {
return;
}
if (this.nuovaPassword.invalid || this.confermaPassword.invalid) {
this.nuovaPassword.markAsTouched();
this.confermaPassword.markAsTouched();
return;
}
if (this.nuovaPassword.value !== this.confermaPassword.value) {
this.cambioPasswordError.set('Le due password non coincidono.');
return;
}
this.cambioPassword.set(true);
this.cambioPasswordError.set(null);
this.cambioPasswordOk.set(false);
try {
await firstValueFrom(this.utenteApi.updatePassword(this.nuovaPassword.value));
this.cambioPasswordOk.set(true);
this.nuovaPassword.reset('');
this.confermaPassword.reset('');
} catch {
this.cambioPasswordError.set('Impossibile aggiornare la password. Riprova più tardi.');
} finally {
this.cambioPassword.set(false);
}
}
tornaAlProfilo(): void {
this.router.navigateByUrl('/profilo');
}
}
@@ -8,6 +8,11 @@
margin: 0 0 20px;
}
.profilo__account-btn {
border-radius: 999px !important;
margin-bottom: 20px;
}
.profilo-card {
max-width: 420px;
background: var(--mat-sys-surface-container);
@@ -1,6 +1,10 @@
<section class="profilo">
<h1 class="profilo__title">Il tuo profilo</h1>
<button class="profilo__account-btn" mat-stroked-button type="button" routerLink="/profilo/modifica">
Modifica dati di registrazione
</button>
@if (hasOrganization()) {
<div class="profilo-card">
<div class="profilo-card__avatar" [class.profilo-card__avatar--admin]="!ruoloCorrente">
@@ -16,6 +20,13 @@
{{ ruoloCorrente ?? 'Ruolo non disponibile' }}
</span>
</div>
} @else if (isAdmin) {
<div class="profilo-card">
<div class="profilo-card__avatar profilo-card__avatar--admin">A</div>
<div class="profilo-card__group-label">Ruolo</div>
<div class="profilo-card__group-name">Amministratore centrale</div>
<span class="profilo-card__badge">admin</span>
</div>
} @else {
<div class="profilo-paths">
<section class="profilo-path">
@@ -7,5 +7,10 @@ export const PROFILO_ROUTES: Routes = [
path: '',
loadComponent: () => import('./profilo').then((m) => m.Profilo),
canActivate: [requireAuthGuard]
},
{
path: 'modifica',
loadComponent: () => import('./modifica-profilo/modifica-profilo').then((m) => m.ModificaProfilo),
canActivate: [requireAuthGuard]
}
];
@@ -1,5 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js';
import { Observable, of, throwError } from 'rxjs';
@@ -33,6 +34,7 @@ describe('Profilo', () => {
await TestBed.configureTestingModule({
imports: [Profilo],
providers: [
provideRouter([]),
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: options.roles } } } },
{
provide: OrganizationContextService,
+9 -2
View File
@@ -7,6 +7,7 @@ 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 { RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
@@ -20,7 +21,7 @@ type GruppoOption = GruppoPubblicoListItem;
@Component({
selector: 'app-profilo',
imports: [ReactiveFormsModule, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule],
imports: [ReactiveFormsModule, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule, RouterLink],
templateUrl: './profilo.html',
styleUrl: './profilo.css'
})
@@ -39,6 +40,10 @@ export class Profilo {
return RUOLI_ORGANIZZAZIONE.find((ruolo) => roles.includes(ruolo)) ?? null;
}
get isAdmin(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes('admin');
}
readonly gruppiDisponibili = signal<GruppoOption[]>([]);
readonly caricandoGruppi = signal(true);
@@ -48,7 +53,9 @@ export class Profilo {
readonly richiestaIngressoInviata = signal(false);
constructor() {
this.caricaGruppiDisponibili();
if (!this.isAdmin) {
this.caricaGruppiDisponibili();
}
}
private async caricaGruppiDisponibili(): Promise<void> {
@@ -0,0 +1,28 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface ProfiloUtente {
email: string;
nome: string;
cognome: string;
}
@Injectable({ providedIn: 'root' })
export class UtenteApiService {
private readonly http = inject(HttpClient);
getMe(): Observable<ProfiloUtente> {
return this.http.get<ProfiloUtente>(`${environment.orgServiceApiBaseUrl}/me`);
}
updateMe(profilo: ProfiloUtente): Observable<ProfiloUtente> {
return this.http.put<ProfiloUtente>(`${environment.orgServiceApiBaseUrl}/me`, profilo);
}
updatePassword(password: string): Observable<void> {
return this.http.put<void>(`${environment.orgServiceApiBaseUrl}/me/password`, { password });
}
}
@@ -1,11 +0,0 @@
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]
}
];