diff --git a/docker-compose.yml b/docker-compose.yml index c4725c6..52a86b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -185,6 +185,7 @@ services: - "${KEYCLOAK_PORT}:8080" volumes: - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + - ./keycloak/themes/scouthub:/opt/keycloak/themes/scouthub:ro depends_on: - keycloak-db healthcheck: diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index b285234..5b55645 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -6,8 +6,12 @@ "registrationAllowed": true, "registrationEmailAsUsername": true, "loginWithEmailAllowed": true, + "editUsernameAllowed": true, "resetPasswordAllowed": true, "verifyEmail": false, + "displayName": "Scouthub", + "displayNameHtml": "Scouthub", + "loginTheme": "scouthub", "roles": { "realm": [ diff --git a/keycloak/themes/scouthub/login/resources/css/scouthub.css b/keycloak/themes/scouthub/login/resources/css/scouthub.css new file mode 100644 index 0000000..fc69df7 --- /dev/null +++ b/keycloak/themes/scouthub/login/resources/css/scouthub.css @@ -0,0 +1,146 @@ +/* + * Palette e font presi da scouthub-home-fe/src/material-theme.scss + * (design system "Scouthub - Home"), per far combaciare login/registrazione + * Keycloak con la grafica del frontend home. scouthub-home-fe forza + * `color-scheme: light` (nessuna dark mode), quindi qui i colori sono + * valori fissi e non seguono il toggle dark del tema keycloak.v2. + */ +@import url('https://fonts.googleapis.com/css2?family=Caprasimo&family=Figtree:wght@400;500;600;700&display=swap'); + +:root { + --scouthub-primary: #c67139; + --scouthub-on-primary: #f5ead8; + --scouthub-primary-container: #fff2eb; + --scouthub-on-primary-container: #643312; + --scouthub-secondary: #7a8a5e; + --scouthub-error: #8c2f22; + --scouthub-background: #f5ead8; + --scouthub-on-background: #201e1d; + --scouthub-surface-container: #ebddc5; + --scouthub-surface-container-lowest: #ffffff; + --scouthub-outline: #a89a83; + --scouthub-outline-variant: #d8c9ab; + + color-scheme: light; + /* Nessuna striscia colorata in cima alla card: home-fe non usa questo + pattern (le sue card, es. profilo-card/crea-gruppo__card, sono piatte). */ + --keycloak-card-top-color: transparent; +} + +html, +body { + font-family: 'Figtree', sans-serif; +} + +.login-pf body, +#keycloak-bg { + background: var(--scouthub-background); + color: var(--scouthub-on-background); +} + +.pf-v5-c-login__main-header { + border-top: none; +} + +.pf-v5-c-login__main { + /* Stesso tono delle card di home-fe (profilo-card, crea-gruppo__card), + non bianco: qui il bianco sarebbe l'unico elemento "freddo" della pagina. */ + background: var(--scouthub-surface-container); + border-radius: 32px; + box-shadow: 0 12px 32px rgba(46, 43, 37, 0.16); + padding: 8px; +} + +#kc-header-wrapper { + font-family: 'Caprasimo', sans-serif; + text-transform: none; + letter-spacing: normal; + font-size: 28px; + color: var(--scouthub-on-background) !important; +} + +.pf-v5-c-title.pf-m-3xl, +#kc-page-title { + font-family: 'Caprasimo', sans-serif; + color: var(--scouthub-on-background); +} + +a, +a:visited { + color: var(--scouthub-primary); +} + +.pf-v5-c-form__label-text { + font-family: 'Figtree', sans-serif; + font-weight: 500; + font-size: 13px; + color: var(--scouthub-on-background); + opacity: 0.75; +} + +.pf-v5-c-form-control { + background-color: var(--scouthub-surface-container-lowest); + color: var(--scouthub-on-background); + border-radius: 12px; + border-color: var(--scouthub-outline-variant); +} + +/* La classe kcInputClass è sullo che avvolge l', non + sull'input stesso: :focus non scatterebbe mai, serve :focus-within. + L'input interno non ha classi proprie, quindi va spento il suo outline + nativo separatamente. */ +.pf-v5-c-form-control:focus-within { + border-color: var(--scouthub-primary); + box-shadow: 0 0 0 1px var(--scouthub-primary); +} + +.pf-v5-c-form-control input:focus, +.pf-v5-c-form-control textarea:focus, +.pf-v5-c-form-control select:focus { + outline: none; +} + +/* PatternFly disegna l'indicatore di focus come bordo inferiore blu tramite + uno pseudo-elemento ::after sullo wrapper (non tramite :focus né + outline): va sovrascritto qui, altrimenti resta il blu di default. */ +.pf-v5-c-form-control::after { + border-bottom-color: var(--scouthub-primary); +} + +.pf-v5-c-button.pf-m-primary { + background-color: var(--scouthub-primary) !important; + color: var(--scouthub-on-primary) !important; + border-radius: 999px !important; + border: none !important; +} + +.pf-v5-c-button.pf-m-primary:hover, +.pf-v5-c-button.pf-m-primary:focus { + background-color: var(--scouthub-on-primary-container) !important; +} + +.pf-v5-c-button.pf-m-secondary { + color: var(--scouthub-secondary) !important; + border-color: var(--scouthub-secondary) !important; + border-radius: 999px !important; +} + +.pf-v5-c-button.pf-m-link { + color: var(--scouthub-primary) !important; +} + +.pf-v5-c-button.pf-m-control { + border-radius: 12px; + border-color: var(--scouthub-outline-variant); + color: var(--scouthub-on-background); +} + +.pf-v5-c-check__input:checked ~ .pf-v5-c-check__label::before { + background-color: var(--scouthub-primary); +} + +.pf-v5-c-alert.pf-m-danger { + --pf-v5-c-alert--BackgroundColor: var(--scouthub-primary-container); + --pf-v5-c-alert--m-danger--BorderTopColor: var(--scouthub-error); + color: var(--scouthub-on-primary-container); +} diff --git a/keycloak/themes/scouthub/login/theme.properties b/keycloak/themes/scouthub/login/theme.properties new file mode 100644 index 0000000..25aac60 --- /dev/null +++ b/keycloak/themes/scouthub/login/theme.properties @@ -0,0 +1,19 @@ +# Tema di login/registrazione allineato alla grafica di scouthub-home-fe +# (stessi colori/font del design system "Scouthub - Home", vedi +# scouthub-home-fe/src/material-theme.scss). Eredita struttura e +# comportamento dal tema built-in "keycloak.v2", aggiungendo solo un foglio +# di stile che sovrascrive i colori PatternFly. +parent=keycloak.v2 +import=common/keycloak + +# Non chiamare il nostro foglio di stile "css/styles.css": è lo stesso path +# relativo usato dal tema padre keycloak.v2 (che contiene l'override del +# layout a colonna singola dell'header). Con lo stesso nome Keycloak serve +# solo il file del tema figlio, perdendo quello del padre: elenchiamo +# entrambi, il nostro per ultimo così vince in cascata sui colori. +styles=css/styles.css css/scouthub.css + +# scouthub-home-fe non ha una dark mode (color-scheme fissato a light in +# material-theme.scss): disattiviamo anche qui il toggle automatico di +# keycloak.v2, altrimenti col sistema in dark mode gli input diventano neri. +darkMode=false diff --git a/scouthub-home-be/src/app.ts b/scouthub-home-be/src/app.ts index 294c4f6..8da9ca5 100644 --- a/scouthub-home-be/src/app.ts +++ b/scouthub-home-be/src/app.ts @@ -7,6 +7,7 @@ import { membriRouter } from './routes/membri.routes'; import { linkIngressoRouter } from './routes/linkIngresso.routes'; import { richiesteIngressoRouter } from './routes/richiesteIngresso.routes'; import { richiesteCreazioneGruppoRouter } from './routes/richiesteCreazioneGruppo.routes'; +import { utenteRouter } from './routes/utente.routes'; import { errorHandler } from './middleware/errorHandler'; export const app = express(); @@ -21,6 +22,7 @@ app.use(membriRouter); app.use(linkIngressoRouter); app.use(richiesteIngressoRouter); app.use(richiesteCreazioneGruppoRouter); +app.use(utenteRouter); app.use((req, res) => { res.status(404).json({ message: 'not found' }); diff --git a/scouthub-home-be/src/controllers/gruppi.controller.ts b/scouthub-home-be/src/controllers/gruppi.controller.ts index c47ccd5..7f81367 100644 --- a/scouthub-home-be/src/controllers/gruppi.controller.ts +++ b/scouthub-home-be/src/controllers/gruppi.controller.ts @@ -34,7 +34,7 @@ function parseBody(body: PostGruppoBody): { nome: string; regione?: string; ruol export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise { try { const input = parseBody(req.body ?? {}); - const result = await createGruppo({ ...input, userId: req.auth!.userId }); + const result = await createGruppo(input); res.status(201).json(result); } catch (err) { next(err); diff --git a/scouthub-home-be/src/controllers/utente.controller.ts b/scouthub-home-be/src/controllers/utente.controller.ts new file mode 100644 index 0000000..bffb40f --- /dev/null +++ b/scouthub-home-be/src/controllers/utente.controller.ts @@ -0,0 +1,55 @@ +import { Request, Response, NextFunction } from 'express'; +import { getProfiloUtente, aggiornaProfiloUtente, cambiaPasswordUtente } from '../services/utente.service'; +import { HttpError } from '../errors'; + +interface PutProfiloBody { + email?: unknown; + nome?: unknown; + cognome?: unknown; +} + +function parseProfiloBody(body: PutProfiloBody): { email: string; nome: string; cognome: string } { + if (typeof body.email !== 'string' || body.email.trim().length === 0) { + throw new HttpError(400, "Il campo 'email' è obbligatorio ed è una stringa non vuota"); + } + if (typeof body.nome !== 'string' || body.nome.trim().length === 0) { + throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota"); + } + if (typeof body.cognome !== 'string' || body.cognome.trim().length === 0) { + throw new HttpError(400, "Il campo 'cognome' è obbligatorio ed è una stringa non vuota"); + } + return { email: body.email.trim(), nome: body.nome.trim(), cognome: body.cognome.trim() }; +} + +export async function getMe(req: Request, res: Response, next: NextFunction): Promise { + try { + const profilo = await getProfiloUtente(req.auth!.userId); + res.json(profilo); + } catch (err) { + next(err); + } +} + +export async function putMe(req: Request, res: Response, next: NextFunction): Promise { + try { + const input = parseProfiloBody(req.body ?? {}); + await aggiornaProfiloUtente(req.auth!.userId, input); + res.status(200).json(input); + } catch (err) { + next(err); + } +} + +export async function putMePassword(req: Request, res: Response, next: NextFunction): Promise { + try { + const { password } = req.body ?? {}; + if (typeof password !== 'string' || password.length < 8) { + throw new HttpError(400, "Il campo 'password' è obbligatorio e deve avere almeno 8 caratteri"); + } + + await cambiaPasswordUtente(req.auth!.userId, password); + res.status(204).send(); + } catch (err) { + next(err); + } +} diff --git a/scouthub-home-be/src/keycloak-admin/index.ts b/scouthub-home-be/src/keycloak-admin/index.ts index c65ccb6..3363c26 100644 --- a/scouthub-home-be/src/keycloak-admin/index.ts +++ b/scouthub-home-be/src/keycloak-admin/index.ts @@ -9,4 +9,5 @@ export { } from './organizations'; export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups'; export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles'; -export { findUserByEmail } from './users'; +export { findUserByEmail, getUserById, updateUserProfile, resetUserPassword } from './users'; +export type { KeycloakUserProfile, UpdateUserProfileInput } from './users'; diff --git a/scouthub-home-be/src/keycloak-admin/users.ts b/scouthub-home-be/src/keycloak-admin/users.ts index 392f012..52b4ca9 100644 --- a/scouthub-home-be/src/keycloak-admin/users.ts +++ b/scouthub-home-be/src/keycloak-admin/users.ts @@ -8,3 +8,52 @@ export async function findUserByEmail(email: string): Promise { + const response = await keycloakAdminHttp.get<{ + id: string; + email?: string; + firstName?: string; + lastName?: string; + }>(`/users/${userId}`); + + return { + id: response.data.id, + email: response.data.email ?? '', + firstName: response.data.firstName ?? '', + lastName: response.data.lastName ?? '', + }; +} + +export interface UpdateUserProfileInput { + email: string; + firstName: string; + lastName: string; +} + +// Il realm ha `registrationEmailAsUsername: true`, quindi username ed email +// devono restare sincronizzati: aggiornare l'email senza lo username +// lascerebbe l'utente con un login (username) diverso dalla nuova email. +export async function updateUserProfile(userId: string, input: UpdateUserProfileInput): Promise { + await keycloakAdminHttp.put(`/users/${userId}`, { + email: input.email, + username: input.email, + firstName: input.firstName, + lastName: input.lastName, + }); +} + +export async function resetUserPassword(userId: string, password: string): Promise { + await keycloakAdminHttp.put(`/users/${userId}/reset-password`, { + type: 'password', + value: password, + temporary: false, + }); +} diff --git a/scouthub-home-be/src/routes/utente.routes.ts b/scouthub-home-be/src/routes/utente.routes.ts new file mode 100644 index 0000000..65ab730 --- /dev/null +++ b/scouthub-home-be/src/routes/utente.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { authenticate } from '../middleware/authenticate'; +import { getMe, putMe, putMePassword } from '../controllers/utente.controller'; + +export const utenteRouter = Router(); + +// Nessun controllo di ruolo/org: l'utente autenticato può leggere e +// modificare solo il proprio profilo (userId preso da req.auth, mai da input client). +utenteRouter.get('/me', authenticate, getMe); +utenteRouter.put('/me', authenticate, putMe); +utenteRouter.put('/me/password', authenticate, putMePassword); diff --git a/scouthub-home-be/src/services/gruppi.service.ts b/scouthub-home-be/src/services/gruppi.service.ts index 559cc2f..b2f17a3 100644 --- a/scouthub-home-be/src/services/gruppi.service.ts +++ b/scouthub-home-be/src/services/gruppi.service.ts @@ -1,13 +1,12 @@ import axios from 'axios'; import { prisma } from '../db/prisma'; -import { createOrganization, createOrganizationGroup, addMemberToOrganization } from '../keycloak-admin'; +import { createOrganization, createOrganizationGroup } from '../keycloak-admin'; import { HttpError } from '../errors'; export const RUOLI_DEFAULT = ['Capi', 'Aiuto capi', 'Censiti']; export interface CreateGruppoInput { nome: string; - userId: string; regione?: string; ruoliDefault?: string[]; } @@ -87,31 +86,20 @@ export async function createGruppo(input: CreateGruppoInput): Promise { const richiesta = await trovaRichiestaPending(id); - // Step 1-4: stessa logica di POST /gruppi (organizzazione, gruppi ruolo, - // membership del richiedente, riga locale). In caso di fallimento - // createGruppo lancia già il proprio HttpError con il dettaglio dello step, - // e la richiesta resta 'PENDING' perché non viene ancora aggiornata qui. + // Step 1-3: stessa logica di POST /gruppi (organizzazione, gruppi ruolo, + // riga locale). In caso di fallimento createGruppo lancia già il proprio + // HttpError con il dettaglio dello step, e la richiesta resta 'PENDING' + // perché non viene ancora aggiornata qui. A differenza di POST /gruppi + // (dove l'admin crea il gruppo senza entrarne a far parte), qui il + // richiedente diventa membro dell'organizzazione e capo-gruppo. const { orgId } = await createGruppo({ nome: richiesta.nomeProposto, regione: richiesta.regione ?? undefined, - userId: richiesta.userId, }); - // Step 5: il richiedente diventa capo-gruppo del gruppo appena creato. + // Step 4: il richiedente diventa membro dell'organizzazione e capo-gruppo + // del gruppo appena creato. try { + await addMemberToOrganization(orgId, richiesta.userId); await assegnaGruppoERuolo(richiesta.userId, RUOLO_CAPO_GRUPPO); } catch (err) { console.error( - `[richieste-creazione-gruppo] STEP 5 (assegnazione ruolo "${RUOLO_CAPO_GRUPPO}") fallito per ` + + `[richieste-creazione-gruppo] STEP 4 (membership e ruolo "${RUOLO_CAPO_GRUPPO}") fallito per ` + `richiestaId="${id}", orgId="${orgId}", userId="${richiesta.userId}". Il gruppo scout è già stato ` + 'creato su Keycloak e localmente: per il retry manuale non richiamare createGruppo, ma solo ' + - `l'assegnazione del ruolo "${RUOLO_CAPO_GRUPPO}" all'utente userId="${richiesta.userId}".`, + `l'aggiunta a membro e l'assegnazione del ruolo "${RUOLO_CAPO_GRUPPO}" all'utente userId="${richiesta.userId}".`, err, ); - throw new HttpError(502, "Impossibile completare l'assegnazione del ruolo capo-gruppo su Keycloak"); + throw new HttpError(502, "Impossibile completare l'assegnazione a membro/capo-gruppo su Keycloak"); } const aggiornata = await prisma.richiestaCreazioneGruppo.update({ diff --git a/scouthub-home-be/src/services/utente.service.ts b/scouthub-home-be/src/services/utente.service.ts new file mode 100644 index 0000000..84d8557 --- /dev/null +++ b/scouthub-home-be/src/services/utente.service.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { getUserById, updateUserProfile, resetUserPassword } from '../keycloak-admin'; +import { HttpError } from '../errors'; + +export interface ProfiloUtente { + email: string; + nome: string; + cognome: string; +} + +export async function getProfiloUtente(userId: string): Promise { + const utente = await getUserById(userId); + return { email: utente.email, nome: utente.firstName, cognome: utente.lastName }; +} + +export interface AggiornaProfiloInput { + email: string; + nome: string; + cognome: string; +} + +export async function aggiornaProfiloUtente(userId: string, input: AggiornaProfiloInput): Promise { + try { + await updateUserProfile(userId, { email: input.email, firstName: input.nome, lastName: input.cognome }); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 409) { + throw new HttpError(409, 'Esiste già un utente registrato con questa email'); + } + console.error(`[utente] aggiornamento profilo fallito per userId="${userId}".`, err); + throw new HttpError(502, "Impossibile aggiornare il profilo su Keycloak"); + } +} + +export async function cambiaPasswordUtente(userId: string, nuovaPassword: string): Promise { + try { + await resetUserPassword(userId, nuovaPassword); + } catch (err) { + console.error(`[utente] cambio password fallito per userId="${userId}".`, err); + throw new HttpError(502, 'Impossibile aggiornare la password su Keycloak'); + } +} diff --git a/scouthub-home-fe/angular.json b/scouthub-home-fe/angular.json index 8d7f089..250b098 100644 --- a/scouthub-home-fe/angular.json +++ b/scouthub-home-fe/angular.json @@ -59,7 +59,7 @@ "serve": { "builder": "@angular/build:dev-server", "options": { - "port": 4201 + "port": 7000 }, "configurations": { "production": { diff --git a/scouthub-home-fe/src/app/app.css b/scouthub-home-fe/src/app/app.css index 4317acd..7d58987 100644 --- a/scouthub-home-fe/src/app/app.css +++ b/scouthub-home-fe/src/app/app.css @@ -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; diff --git a/scouthub-home-fe/src/app/app.routes.ts b/scouthub-home-fe/src/app/app.routes.ts index 36cad75..810cc0a 100644 --- a/scouthub-home-fe/src/app/app.routes.ts +++ b/scouthub-home-fe/src/app/app.routes.ts @@ -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', diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css index 2cbbf95..12012cd 100644 --- a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.css @@ -49,3 +49,8 @@ .gruppi-list__link:hover { text-decoration: underline; } + +app-richieste-creazione-gruppo { + display: block; + margin-top: 40px; +} diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html index 77bb449..23fe02b 100644 --- a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.html @@ -32,4 +32,6 @@ } + + diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts index e6c4dad..52736be 100644 --- a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.spec.ts @@ -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 { await TestBed.configureTestingModule({ imports: [GruppiList], - providers: [provideRouter([]), { provide: GruppiApiService, useValue: gruppiApi }] + providers: [ + provideRouter([]), + { provide: GruppiApiService, useValue: gruppiApi }, + // GruppiList incorpora , 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); diff --git a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts index 6339d86..1bec2ad 100644 --- a/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts +++ b/scouthub-home-fe/src/app/gruppi/gruppi-list/gruppi-list.ts @@ -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' }) diff --git a/scouthub-home-fe/src/app/home/home.css b/scouthub-home-fe/src/app/home/home.css index b4c5dd2..23ef520 100644 --- a/scouthub-home-fe/src/app/home/home.css +++ b/scouthub-home-fe/src/app/home/home.css @@ -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); -} diff --git a/scouthub-home-fe/src/app/home/home.html b/scouthub-home-fe/src/app/home/home.html index 6c389b2..c9d0c8c 100644 --- a/scouthub-home-fe/src/app/home/home.html +++ b/scouthub-home-fe/src/app/home/home.html @@ -31,14 +31,4 @@ } - - @if (isAdmin) { -
-

Amministrazione

- -
- } diff --git a/scouthub-home-fe/src/app/home/home.spec.ts b/scouthub-home-fe/src/app/home/home.spec.ts index 5dfd7a9..6865f49 100644 --- a/scouthub-home-fe/src/app/home/home.spec.ts +++ b/scouthub-home-fe/src/app/home/home.spec.ts @@ -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'); - }); - }); }); diff --git a/scouthub-home-fe/src/app/home/home.ts b/scouthub-home-fe/src/app/home/home.ts index 2cee86b..9c72865 100644 --- a/scouthub-home-fe/src/app/home/home.ts +++ b/scouthub-home-fe/src/app/home/home.ts @@ -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 }); } diff --git a/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.css b/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.css new file mode 100644 index 0000000..0b185ac --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.css @@ -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; +} diff --git a/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.html b/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.html new file mode 100644 index 0000000..4a303f3 --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.html @@ -0,0 +1,97 @@ +
+

Modifica dati di registrazione

+ + @if (caricamento()) { +

Caricamento…

+ } @else if (caricamentoError(); as message) { + + } @else { +
+

Dati personali

+ +
+ + Nome + + @if (nome.hasError('required')) { + Il nome è obbligatorio. + } + + + + Cognome + + @if (cognome.hasError('required')) { + Il cognome è obbligatorio. + } + + + + Email + + @if (email.hasError('required')) { + L'email è obbligatoria. + } @else if (email.hasError('email')) { + Inserisci un'email valida. + } + + + @if (salvataggioError(); as message) { + + } + + @if (salvataggioOk()) { +

Dati aggiornati con successo.

+ } + +
+ +
+
+
+ +
+

Cambia password

+ +
+ + Nuova password + + @if (nuovaPassword.hasError('required')) { + La nuova password è obbligatoria. + } @else if (nuovaPassword.hasError('minlength')) { + La password deve avere almeno 8 caratteri. + } + + + + Conferma password + + @if (confermaPassword.hasError('required')) { + Conferma la nuova password. + } + + + @if (cambioPasswordError(); as message) { + + } + + @if (cambioPasswordOk()) { +

Password aggiornata con successo.

+ } + +
+ +
+
+
+ } + + +
diff --git a/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.ts b/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.ts new file mode 100644 index 0000000..cfa9f82 --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/modifica-profilo/modifica-profilo.ts @@ -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(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(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(null); + readonly cambioPasswordOk = signal(false); + + constructor() { + this.caricaProfilo(); + } + + private async caricaProfilo(): Promise { + 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 { + 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 { + 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'); + } +} diff --git a/scouthub-home-fe/src/app/profilo/profilo.css b/scouthub-home-fe/src/app/profilo/profilo.css index 05935c0..9714b5f 100644 --- a/scouthub-home-fe/src/app/profilo/profilo.css +++ b/scouthub-home-fe/src/app/profilo/profilo.css @@ -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); diff --git a/scouthub-home-fe/src/app/profilo/profilo.html b/scouthub-home-fe/src/app/profilo/profilo.html index d591726..8db7389 100644 --- a/scouthub-home-fe/src/app/profilo/profilo.html +++ b/scouthub-home-fe/src/app/profilo/profilo.html @@ -1,6 +1,10 @@

Il tuo profilo

+ + @if (hasOrganization()) {
@@ -16,6 +20,13 @@ {{ ruoloCorrente ?? 'Ruolo non disponibile' }}
+ } @else if (isAdmin) { +
+
A
+
Ruolo
+
Amministratore centrale
+ admin +
} @else {
diff --git a/scouthub-home-fe/src/app/profilo/profilo.routes.ts b/scouthub-home-fe/src/app/profilo/profilo.routes.ts index 8724c43..edac752 100644 --- a/scouthub-home-fe/src/app/profilo/profilo.routes.ts +++ b/scouthub-home-fe/src/app/profilo/profilo.routes.ts @@ -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] } ]; diff --git a/scouthub-home-fe/src/app/profilo/profilo.spec.ts b/scouthub-home-fe/src/app/profilo/profilo.spec.ts index baea1bd..6f5d0d3 100644 --- a/scouthub-home-fe/src/app/profilo/profilo.spec.ts +++ b/scouthub-home-fe/src/app/profilo/profilo.spec.ts @@ -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, diff --git a/scouthub-home-fe/src/app/profilo/profilo.ts b/scouthub-home-fe/src/app/profilo/profilo.ts index 54adaae..f884e80 100644 --- a/scouthub-home-fe/src/app/profilo/profilo.ts +++ b/scouthub-home-fe/src/app/profilo/profilo.ts @@ -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([]); 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 { diff --git a/scouthub-home-fe/src/app/profilo/utente-api.service.ts b/scouthub-home-fe/src/app/profilo/utente-api.service.ts new file mode 100644 index 0000000..120cfad --- /dev/null +++ b/scouthub-home-fe/src/app/profilo/utente-api.service.ts @@ -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 { + return this.http.get(`${environment.orgServiceApiBaseUrl}/me`); + } + + updateMe(profilo: ProfiloUtente): Observable { + return this.http.put(`${environment.orgServiceApiBaseUrl}/me`, profilo); + } + + updatePassword(password: string): Observable { + return this.http.put(`${environment.orgServiceApiBaseUrl}/me/password`, { password }); + } +} diff --git a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.routes.ts b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.routes.ts deleted file mode 100644 index 75bd2dd..0000000 --- a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.routes.ts +++ /dev/null @@ -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] - } -];