Sistemato home e keycloak
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
-11
@@ -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]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
Reference in New Issue
Block a user