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
+2
View File
@@ -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' });
@@ -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> {
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);
@@ -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';
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';
@@ -8,3 +8,52 @@ export async function findUserByEmail(email: string): Promise<KeycloakUserSummar
const [utente] = response.data;
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 { 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<CreateGrup
}
}
// Step 3: il creatore diventa membro dell'organizzazione appena creata.
// Senza questo passaggio il claim "organization" non comparirebbe mai nel
// suo token, e la auth guard del FE lo rimanderebbe sempre su "crea gruppo"
// anche a creazione riuscita.
try {
await addMemberToOrganization(orgId, input.userId);
} 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.
// Step 3: riga locale in gruppo_scout.
// Nota: chi chiama createGruppo (admin via POST /gruppi, oppure l'approvazione
// di una richiesta di creazione gruppo) NON diventa automaticamente membro
// dell'organizzazione qui: l'admin che crea un gruppo per conto di altri non deve
// entrarne a far parte, mentre il richiedente che diventa capo-gruppo viene reso
// membro esplicitamente dal chiamante (vedi richiesteCreazioneGruppo.service.ts).
try {
await prisma.gruppoScout.create({
data: { orgId, nome: input.nome, regione: input.regione },
});
} catch (err) {
console.error(
`[gruppi] STEP 4 (salvataggio locale gruppo_scout) fallito per orgId="${orgId}", nome="${input.nome}". ` +
`Organizzazione, gruppi [${gruppiCreati.join(', ')}] e membership già creati su Keycloak: per il ` +
`[gruppi] STEP 3 (salvataggio locale gruppo_scout) fallito per orgId="${orgId}", nome="${input.nome}". ` +
`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}".`,
err,
);
@@ -1,6 +1,7 @@
import { prisma } from '../db/prisma';
import { HttpError } from '../errors';
import { createGruppo } from './gruppi.service';
import { addMemberToOrganization } from '../keycloak-admin';
import { assegnaGruppoERuolo } from './assegnazioneGruppoRuolo';
const STATO_RICHIESTA = {
@@ -91,28 +92,31 @@ async function trovaRichiestaPending(id: string) {
export async function approvaRichiesta(id: string): Promise<RisolviRichiestaResult> {
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({
@@ -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');
}
}