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
View File
@@ -185,6 +185,7 @@ services:
- "${KEYCLOAK_PORT}:8080" - "${KEYCLOAK_PORT}:8080"
volumes: volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
- ./keycloak/themes/scouthub:/opt/keycloak/themes/scouthub:ro
depends_on: depends_on:
- keycloak-db - keycloak-db
healthcheck: healthcheck:
+4
View File
@@ -6,8 +6,12 @@
"registrationAllowed": true, "registrationAllowed": true,
"registrationEmailAsUsername": true, "registrationEmailAsUsername": true,
"loginWithEmailAllowed": true, "loginWithEmailAllowed": true,
"editUsernameAllowed": true,
"resetPasswordAllowed": true, "resetPasswordAllowed": true,
"verifyEmail": false, "verifyEmail": false,
"displayName": "Scouthub",
"displayNameHtml": "Scouthub",
"loginTheme": "scouthub",
"roles": { "roles": {
"realm": [ "realm": [
@@ -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 <span> che avvolge l'<input>, 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 <span> 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);
}
@@ -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
+2
View File
@@ -7,6 +7,7 @@ import { membriRouter } from './routes/membri.routes';
import { linkIngressoRouter } from './routes/linkIngresso.routes'; import { linkIngressoRouter } from './routes/linkIngresso.routes';
import { richiesteIngressoRouter } from './routes/richiesteIngresso.routes'; import { richiesteIngressoRouter } from './routes/richiesteIngresso.routes';
import { richiesteCreazioneGruppoRouter } from './routes/richiesteCreazioneGruppo.routes'; import { richiesteCreazioneGruppoRouter } from './routes/richiesteCreazioneGruppo.routes';
import { utenteRouter } from './routes/utente.routes';
import { errorHandler } from './middleware/errorHandler'; import { errorHandler } from './middleware/errorHandler';
export const app = express(); export const app = express();
@@ -21,6 +22,7 @@ app.use(membriRouter);
app.use(linkIngressoRouter); app.use(linkIngressoRouter);
app.use(richiesteIngressoRouter); app.use(richiesteIngressoRouter);
app.use(richiesteCreazioneGruppoRouter); app.use(richiesteCreazioneGruppoRouter);
app.use(utenteRouter);
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ message: 'not found' }); res.status(404).json({ message: 'not found' });
@@ -34,7 +34,7 @@ function parseBody(body: PostGruppoBody): { nome: string; regione?: string; ruol
export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise<void> { export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
try { try {
const input = parseBody(req.body ?? {}); const input = parseBody(req.body ?? {});
const result = await createGruppo({ ...input, userId: req.auth!.userId }); const result = await createGruppo(input);
res.status(201).json(result); res.status(201).json(result);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -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<void> {
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<void> {
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<void> {
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);
}
}
+2 -1
View File
@@ -9,4 +9,5 @@ export {
} from './organizations'; } from './organizations';
export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups'; export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups';
export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles'; export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles';
export { findUserByEmail } from './users'; export { findUserByEmail, getUserById, updateUserProfile, resetUserPassword } from './users';
export type { KeycloakUserProfile, UpdateUserProfileInput } from './users';
@@ -8,3 +8,52 @@ export async function findUserByEmail(email: string): Promise<KeycloakUserSummar
const [utente] = response.data; const [utente] = response.data;
return utente ? { id: utente.id } : null; return utente ? { id: utente.id } : null;
} }
export interface KeycloakUserProfile {
id: string;
email: string;
firstName: string;
lastName: string;
}
export async function getUserById(userId: string): Promise<KeycloakUserProfile> {
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<void> {
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<void> {
await keycloakAdminHttp.put(`/users/${userId}/reset-password`, {
type: 'password',
value: password,
temporary: false,
});
}
@@ -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);
@@ -1,13 +1,12 @@
import axios from 'axios'; import axios from 'axios';
import { prisma } from '../db/prisma'; import { prisma } from '../db/prisma';
import { createOrganization, createOrganizationGroup, addMemberToOrganization } from '../keycloak-admin'; import { createOrganization, createOrganizationGroup } from '../keycloak-admin';
import { HttpError } from '../errors'; import { HttpError } from '../errors';
export const RUOLI_DEFAULT = ['Capi', 'Aiuto capi', 'Censiti']; export const RUOLI_DEFAULT = ['Capi', 'Aiuto capi', 'Censiti'];
export interface CreateGruppoInput { export interface CreateGruppoInput {
nome: string; nome: string;
userId: string;
regione?: string; regione?: string;
ruoliDefault?: string[]; ruoliDefault?: string[];
} }
@@ -87,31 +86,20 @@ export async function createGruppo(input: CreateGruppoInput): Promise<CreateGrup
} }
} }
// Step 3: il creatore diventa membro dell'organizzazione appena creata. // Step 3: riga locale in gruppo_scout.
// Senza questo passaggio il claim "organization" non comparirebbe mai nel // Nota: chi chiama createGruppo (admin via POST /gruppi, oppure l'approvazione
// suo token, e la auth guard del FE lo rimanderebbe sempre su "crea gruppo" // di una richiesta di creazione gruppo) NON diventa automaticamente membro
// anche a creazione riuscita. // dell'organizzazione qui: l'admin che crea un gruppo per conto di altri non deve
try { // entrarne a far parte, mentre il richiedente che diventa capo-gruppo viene reso
await addMemberToOrganization(orgId, input.userId); // membro esplicitamente dal chiamante (vedi richiesteCreazioneGruppo.service.ts).
} catch (err) {
console.error(
`[gruppi] STEP 3 (addMemberToOrganization) fallito per orgId="${orgId}", userId="${input.userId}". ` +
`Organizzazione e gruppi [${gruppiCreati.join(', ')}] già creati su Keycloak: per il retry manuale ` +
`non richiamare le API di creazione, ma solo l'aggiunta del membro con orgId="${orgId}".`,
err,
);
throw new HttpError(502, "Impossibile aggiungere l'utente all'organizzazione su Keycloak");
}
// Step 4: riga locale in gruppo_scout.
try { try {
await prisma.gruppoScout.create({ await prisma.gruppoScout.create({
data: { orgId, nome: input.nome, regione: input.regione }, data: { orgId, nome: input.nome, regione: input.regione },
}); });
} catch (err) { } catch (err) {
console.error( console.error(
`[gruppi] STEP 4 (salvataggio locale gruppo_scout) fallito per orgId="${orgId}", nome="${input.nome}". ` + `[gruppi] STEP 3 (salvataggio locale gruppo_scout) fallito per orgId="${orgId}", nome="${input.nome}". ` +
`Organizzazione, gruppi [${gruppiCreati.join(', ')}] e membership già creati su Keycloak: per il ` + `Organizzazione e gruppi [${gruppiCreati.join(', ')}] già creati su Keycloak: per il ` +
`retry manuale non richiamare le API Keycloak, ma solo il salvataggio locale con orgId="${orgId}".`, `retry manuale non richiamare le API Keycloak, ma solo il salvataggio locale con orgId="${orgId}".`,
err, err,
); );
@@ -1,6 +1,7 @@
import { prisma } from '../db/prisma'; import { prisma } from '../db/prisma';
import { HttpError } from '../errors'; import { HttpError } from '../errors';
import { createGruppo } from './gruppi.service'; import { createGruppo } from './gruppi.service';
import { addMemberToOrganization } from '../keycloak-admin';
import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo'; import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo';
const STATO_RICHIESTA = { const STATO_RICHIESTA = {
@@ -91,28 +92,31 @@ async function trovaRichiestaPending(id: string) {
export async function approvaRichiesta(id: string): Promise<RisolviRichiestaResult> { export async function approvaRichiesta(id: string): Promise<RisolviRichiestaResult> {
const richiesta = await trovaRichiestaPending(id); const richiesta = await trovaRichiestaPending(id);
// Step 1-4: stessa logica di POST /gruppi (organizzazione, gruppi ruolo, // Step 1-3: stessa logica di POST /gruppi (organizzazione, gruppi ruolo,
// membership del richiedente, riga locale). In caso di fallimento // riga locale). In caso di fallimento createGruppo lancia già il proprio
// createGruppo lancia già il proprio HttpError con il dettaglio dello step, // HttpError con il dettaglio dello step, e la richiesta resta 'PENDING'
// e la richiesta resta 'PENDING' perché non viene ancora aggiornata qui. // 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({ const { orgId } = await createGruppo({
nome: richiesta.nomeProposto, nome: richiesta.nomeProposto,
regione: richiesta.regione ?? undefined, 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 { try {
await addMemberToOrganization(orgId, richiesta.userId);
await assegnaGruppoERuolo(richiesta.userId, RUOLO_CAPO_GRUPPO); await assegnaGruppoERuolo(richiesta.userId, RUOLO_CAPO_GRUPPO);
} catch (err) { } catch (err) {
console.error( 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 ` + `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 ' + '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, 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({ const aggiornata = await prisma.richiestaCreazioneGruppo.update({
@@ -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<ProfiloUtente> {
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<void> {
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<void> {
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');
}
}
+1 -1
View File
@@ -59,7 +59,7 @@
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": { "options": {
"port": 4201 "port": 7000
}, },
"configurations": { "configurations": {
"production": { "production": {
+5 -2
View File
@@ -33,6 +33,8 @@
.app-shell__brand-name { .app-shell__brand-name {
font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif); font-family: var(--mat-sys-headline-small-font, Caprasimo, sans-serif);
font-size: 19px; font-size: 19px;
line-height: 1;
display: inline-block;
} }
.app-shell__spacer { .app-shell__spacer {
@@ -47,7 +49,7 @@
border-radius: 999px !important; border-radius: 999px !important;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
} }
.app-shell__avatar { .app-shell__avatar {
@@ -56,7 +58,8 @@
border-radius: 50%; border-radius: 50%;
background: var(--mat-sys-secondary); background: var(--mat-sys-secondary);
color: var(--mat-sys-on-secondary); color: var(--mat-sys-on-secondary);
display: flex; display: inline-flex;
flex: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 11px; font-size: 11px;
-7
View File
@@ -18,13 +18,6 @@ export const routes: Routes = [
path: 'profilo', path: 'profilo',
loadChildren: () => import('./profilo/profilo.routes').then((m) => m.PROFILO_ROUTES) 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. // Pubblica: un invito deve poter essere visualizzato anche da chi non ha ancora un account.
path: 'inviti', path: 'inviti',
@@ -49,3 +49,8 @@
.gruppi-list__link:hover { .gruppi-list__link:hover {
text-decoration: underline; text-decoration: underline;
} }
app-richieste-creazione-gruppo {
display: block;
margin-top: 40px;
}
@@ -32,4 +32,6 @@
</table> </table>
</div> </div>
} }
<app-richieste-creazione-gruppo />
</section> </section>
@@ -1,7 +1,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; 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 { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { RichiesteCreazioneGruppoApiService } from '../../profilo/richieste-creazione-gruppo-api.service';
import { GruppiApiService, GruppoListItem } from '../gruppi-api.service'; import { GruppiApiService, GruppoListItem } from '../gruppi-api.service';
import { GruppiList } from './gruppi-list'; import { GruppiList } from './gruppi-list';
@@ -13,7 +17,16 @@ describe('GruppiList', () => {
async function setup(): Promise<void> { async function setup(): Promise<void> {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [GruppiList], 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(); }).compileComponents();
fixture = TestBed.createComponent(GruppiList); fixture = TestBed.createComponent(GruppiList);
@@ -4,11 +4,12 @@ import { MatTableModule } from '@angular/material/table';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { RichiesteCreazioneGruppo } from '../../richieste-creazione-gruppo/richieste-creazione-gruppo';
import { GruppiApiService, GruppoListItem } from '../gruppi-api.service'; import { GruppiApiService, GruppoListItem } from '../gruppi-api.service';
@Component({ @Component({
selector: 'app-gruppi-list', selector: 'app-gruppi-list',
imports: [MatButtonModule, MatTableModule, RouterLink], imports: [MatButtonModule, MatTableModule, RouterLink, RichiesteCreazioneGruppo],
templateUrl: './gruppi-list.html', templateUrl: './gruppi-list.html',
styleUrl: './gruppi-list.css' styleUrl: './gruppi-list.css'
}) })
-29
View File
@@ -86,32 +86,3 @@
font-size: 12px; font-size: 12px;
color: var(--mat-sys-on-primary-container); 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>
} }
</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> </section>
@@ -67,32 +67,4 @@ describe('Home', () => {
expect(compiled.querySelector('.servizio-card__avviso')).toBeNull(); expect(compiled.querySelector('.servizio-card__avviso')).toBeNull();
expect(compiled.querySelector('.servizio-card button')).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 { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js'; import Keycloak from 'keycloak-js';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { extractRealmRoles } from '../core/auth/roles';
export interface ServizioCard { export interface ServizioCard {
id: string; id: string;
@@ -41,7 +39,7 @@ const SERVIZI: ServizioCard[] = [
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [MatButtonModule, MatCardModule, RouterLink], imports: [MatButtonModule, MatCardModule],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.css' styleUrl: './home.css'
}) })
@@ -54,10 +52,6 @@ export class Home {
return this.keycloak.authenticated ?? false; return this.keycloak.authenticated ?? false;
} }
get isAdmin(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes('admin');
}
login(): void { login(): void {
this.keycloak.login({ redirectUri: window.location.href }); 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; margin: 0 0 20px;
} }
.profilo__account-btn {
border-radius: 999px !important;
margin-bottom: 20px;
}
.profilo-card { .profilo-card {
max-width: 420px; max-width: 420px;
background: var(--mat-sys-surface-container); background: var(--mat-sys-surface-container);
@@ -1,6 +1,10 @@
<section class="profilo"> <section class="profilo">
<h1 class="profilo__title">Il tuo profilo</h1> <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()) { @if (hasOrganization()) {
<div class="profilo-card"> <div class="profilo-card">
<div class="profilo-card__avatar" [class.profilo-card__avatar--admin]="!ruoloCorrente"> <div class="profilo-card__avatar" [class.profilo-card__avatar--admin]="!ruoloCorrente">
@@ -16,6 +20,13 @@
{{ ruoloCorrente ?? 'Ruolo non disponibile' }} {{ ruoloCorrente ?? 'Ruolo non disponibile' }}
</span> </span>
</div> </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 { } @else {
<div class="profilo-paths"> <div class="profilo-paths">
<section class="profilo-path"> <section class="profilo-path">
@@ -7,5 +7,10 @@ export const PROFILO_ROUTES: Routes = [
path: '', path: '',
loadComponent: () => import('./profilo').then((m) => m.Profilo), loadComponent: () => import('./profilo').then((m) => m.Profilo),
canActivate: [requireAuthGuard] 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 { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js'; import Keycloak from 'keycloak-js';
import { Observable, of, throwError } from 'rxjs'; import { Observable, of, throwError } from 'rxjs';
@@ -33,6 +34,7 @@ describe('Profilo', () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Profilo], imports: [Profilo],
providers: [ providers: [
provideRouter([]),
{ provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: options.roles } } } }, { provide: Keycloak, useValue: { tokenParsed: { realm_access: { roles: options.roles } } } },
{ {
provide: OrganizationContextService, provide: OrganizationContextService,
+8 -1
View File
@@ -7,6 +7,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js'; import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
@@ -20,7 +21,7 @@ type GruppoOption = GruppoPubblicoListItem;
@Component({ @Component({
selector: 'app-profilo', selector: 'app-profilo',
imports: [ReactiveFormsModule, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule], imports: [ReactiveFormsModule, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule, MatSelectModule, RouterLink],
templateUrl: './profilo.html', templateUrl: './profilo.html',
styleUrl: './profilo.css' styleUrl: './profilo.css'
}) })
@@ -39,6 +40,10 @@ export class Profilo {
return RUOLI_ORGANIZZAZIONE.find((ruolo) => roles.includes(ruolo)) ?? null; return RUOLI_ORGANIZZAZIONE.find((ruolo) => roles.includes(ruolo)) ?? null;
} }
get isAdmin(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes('admin');
}
readonly gruppiDisponibili = signal<GruppoOption[]>([]); readonly gruppiDisponibili = signal<GruppoOption[]>([]);
readonly caricandoGruppi = signal(true); readonly caricandoGruppi = signal(true);
@@ -48,8 +53,10 @@ export class Profilo {
readonly richiestaIngressoInviata = signal(false); readonly richiestaIngressoInviata = signal(false);
constructor() { constructor() {
if (!this.isAdmin) {
this.caricaGruppiDisponibili(); this.caricaGruppiDisponibili();
} }
}
private async caricaGruppiDisponibili(): Promise<void> { private async caricaGruppiDisponibili(): Promise<void> {
try { try {
@@ -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]
}
];