diff --git a/docker-compose.yml b/docker-compose.yml index ea2e2cc..c4725c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,6 +78,8 @@ services: KEYCLOAK_BASE_URL: http://localhost:${KEYCLOAK_PORT} ORG_SERVICE_API_BASE_URL: http://localhost:${HOME_BACKEND_PORT} ATTIVITA_FE_BASE_URL: http://localhost:${ATTIVITA_FRONTEND_PORT} + MAGAZZINO_FE_BASE_URL: http://localhost:${MAGAZZINO_FRONTEND_PORT} + EVENTI_FE_BASE_URL: http://localhost:${EVENTI_FRONTEND_PORT} restart: unless-stopped ports: - "${HOME_FRONTEND_PORT}:80" @@ -160,13 +162,11 @@ services: keycloak: image: quay.io/keycloak/keycloak:26.7 restart: unless-stopped - # --import-realm importa keycloak/realm-export.json solo se il realm non esiste già - # NOTA: dopo l'import, assegnare al service account di scouthub-home-be i client role - # del client 'realm-management': manage-organizations, manage-users, manage-groups, - # view-users (permessi minimi, non manage-realm). Con Fine-Grained Admin Permissions - # (26.7+, feature admin-fine-grained-authz:v1) si puo' poi restringere ulteriormente - # per singola Organization. - command: start-dev --import-realm + # --import-realm importa keycloak/realm-export.json solo se il realm non esiste già. + # Le correzioni post-import (ruoli service account, protocol mapper "organization") non + # coperte dal file JSON sono automatizzate dal servizio one-shot "keycloak-init" sotto, + # non più manuali (vedi CLAUDE.md). + command: start-dev --import-realm --health-enabled=true environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak @@ -187,6 +187,25 @@ services: - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro depends_on: - keycloak-db + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '\"status\": \"UP\"' <&3"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 30s + + keycloak-init: + image: alpine:3.20 + restart: "no" + entrypoint: ["sh", "-c", "apk add --no-cache curl jq >/dev/null && sh /init-post-import.sh"] + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + volumes: + - ./keycloak/init-post-import.sh:/init-post-import.sh:ro + depends_on: + keycloak: + condition: service_healthy volumes: scouthub_db_data: diff --git a/keycloak/init-post-import.sh b/keycloak/init-post-import.sh new file mode 100644 index 0000000..851450f --- /dev/null +++ b/keycloak/init-post-import.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# Corregge due impostazioni che l'import di realm-export.json non copre (vedi CLAUDE.md): +# 1. il client-scope built-in "organization" (creato da Keycloak con organizationsEnabled:true, +# non presente nel JSON) di default non include l'id dell'org nel claim JWT +# 2. il service account di scouthub-home-be non ha i client role di realm-management necessari +# a operare sulla Admin API (organizations, users, roles) +# +# Idempotente: eseguibile ad ogni avvio senza effetti collaterali se già a posto. +set -eu + +KEYCLOAK_URL="http://keycloak:8080" +REALM="scouthub" +SA_CLIENT_ID="scouthub-home-be" +REQUIRED_REALM_MANAGEMENT_ROLES="manage-organizations manage-users view-realm view-users" + +echo "[keycloak-init] attendo che $KEYCLOAK_URL sia pronto..." +until curl -sf "$KEYCLOAK_URL/realms/master/.well-known/openid-configuration" >/dev/null 2>&1; do + sleep 2 +done + +echo "[keycloak-init] attendo che il realm '$REALM' sia importato..." +until curl -sf "$KEYCLOAK_URL/realms/$REALM/.well-known/openid-configuration" >/dev/null 2>&1; do + sleep 2 +done + +get_admin_token() { + curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli" \ + -d "username=$KEYCLOAK_ADMIN" \ + -d "password=$KEYCLOAK_ADMIN_PASSWORD" \ + -d "grant_type=password" | jq -r '.access_token' +} + +TOKEN="$(get_admin_token)" +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "[keycloak-init] impossibile ottenere il token admin, esco." >&2 + exit 1 +fi + +auth_get() { curl -sf -H "Authorization: Bearer $TOKEN" "$KEYCLOAK_URL$1"; } + +# --- 1. client-scope "organization": abilita addOrganizationId ------------------------------- +SCOPE_ID="$(auth_get "/admin/realms/$REALM/client-scopes" | jq -r '.[] | select(.name=="organization") | .id')" + +if [ -z "$SCOPE_ID" ]; then + echo "[keycloak-init] client-scope 'organization' non trovato, salto." >&2 +else + MAPPER_JSON="$(auth_get "/admin/realms/$REALM/client-scopes/$SCOPE_ID/protocol-mappers/models" \ + | jq -c '.[] | select(.name=="organization")')" + MAPPER_ID="$(echo "$MAPPER_JSON" | jq -r '.id')" + ALREADY_SET="$(echo "$MAPPER_JSON" | jq -r '.config.addOrganizationId // "false"')" + + if [ "$ALREADY_SET" = "true" ]; then + echo "[keycloak-init] mapper 'organization': addOrganizationId già attivo." + else + UPDATED_MAPPER="$(echo "$MAPPER_JSON" | jq '.config.addOrganizationId = "true"')" + curl -sf -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + "$KEYCLOAK_URL/admin/realms/$REALM/client-scopes/$SCOPE_ID/protocol-mappers/models/$MAPPER_ID" \ + -d "$UPDATED_MAPPER" >/dev/null + echo "[keycloak-init] mapper 'organization': addOrganizationId attivato." + fi +fi + +# --- 2. service account di scouthub-home-be: ruoli realm-management --------------------------- +SA_UUID="$(auth_get "/admin/realms/$REALM/clients?clientId=$SA_CLIENT_ID" | jq -r '.[0].id')" +if [ -z "$SA_UUID" ] || [ "$SA_UUID" = "null" ]; then + echo "[keycloak-init] client '$SA_CLIENT_ID' non trovato, salto." >&2 +else + SA_USER_ID="$(auth_get "/admin/realms/$REALM/clients/$SA_UUID/service-account-user" | jq -r '.id')" + RM_UUID="$(auth_get "/admin/realms/$REALM/clients?clientId=realm-management" | jq -r '.[0].id')" + + GIA_ASSEGNATI="$(auth_get "/admin/realms/$REALM/users/$SA_USER_ID/role-mappings/clients/$RM_UUID" | jq -r '.[].name')" + DISPONIBILI="$(auth_get "/admin/realms/$REALM/clients/$RM_UUID/roles")" + + DA_ASSEGNARE="[]" + for ROLE in $REQUIRED_REALM_MANAGEMENT_ROLES; do + if echo "$GIA_ASSEGNATI" | grep -qx "$ROLE"; then + continue + fi + ROLE_REPR="$(echo "$DISPONIBILI" | jq -c --arg name "$ROLE" '.[] | select(.name==$name)')" + if [ -z "$ROLE_REPR" ]; then + echo "[keycloak-init] ruolo '$ROLE' non esiste su questa versione di Keycloak, salto." >&2 + continue + fi + DA_ASSEGNARE="$(echo "$DA_ASSEGNARE" | jq -c --argjson r "$ROLE_REPR" '. + [$r]')" + done + + if [ "$(echo "$DA_ASSEGNARE" | jq 'length')" = "0" ]; then + echo "[keycloak-init] service account '$SA_CLIENT_ID': ruoli realm-management già a posto." + else + curl -sf -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + "$KEYCLOAK_URL/admin/realms/$REALM/users/$SA_USER_ID/role-mappings/clients/$RM_UUID" \ + -d "$DA_ASSEGNARE" >/dev/null + echo "[keycloak-init] service account '$SA_CLIENT_ID': assegnati $(echo "$DA_ASSEGNARE" | jq -r '[.[].name] | join(", ")')." + fi +fi + +echo "[keycloak-init] completato." diff --git a/scouthub-home-be/src/keycloak-admin/organizations.ts b/scouthub-home-be/src/keycloak-admin/organizations.ts index 8aa3a97..fe7cef5 100644 --- a/scouthub-home-be/src/keycloak-admin/organizations.ts +++ b/scouthub-home-be/src/keycloak-admin/organizations.ts @@ -79,9 +79,18 @@ export async function addMemberToOrganization(orgId: string, userId: string): Pr // Verificato contro un'istanza Keycloak 26.7 reale: l'endpoint richiede // Content-Type application/json con il solo id utente come stringa JSON nel // body (non un oggetto, e non text/plain: entrambi rispondono 415). - await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), { - headers: { 'Content-Type': 'application/json' }, - }); + try { + await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (err) { + // Idempotente: un retry dopo un fallimento parziale (es. l'assegnazione + // del ruolo realm successiva fallita) non deve rompersi solo perché + // l'utente era già stato aggiunto all'org nel tentativo precedente. + if (!axios.isAxiosError(err) || err.response?.status !== 409) { + throw err; + } + } } export interface OrganizationMember { diff --git a/scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts b/scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts index db2b52e..992a9fb 100644 --- a/scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts +++ b/scouthub-home-be/src/services/assegnazioneGruppoRuolo.ts @@ -1,9 +1,10 @@ -import { assignUserToGroup, assignRealmRoleToUser } from '../keycloak-admin'; +import { assignRealmRoleToUser } from '../keycloak-admin'; -// "ruolo" è usato qui anche come identificativo del gruppo Keycloak, in -// attesa che createOrganizationGroup persista una mappa ruolo -> groupId -// reale da risolvere in questo punto (vedi gruppi.service.ts). +// "ruolo" qui è sempre un ruolo realm (capo-gruppo/capo-unita/capo/censito/...), +// non un gruppo Keycloak: il gruppo interno all'organizzazione (Capi/Aiuto +// capi/Censiti, creato da createOrganizationGroup) è un concetto distinto, +// oggi non collegato a nessun input utente, e va assegnato a parte quando +// servirà davvero. export async function assegnaGruppoERuolo(userId: string, ruolo: string): Promise { - await assignUserToGroup(userId, ruolo); await assignRealmRoleToUser(userId, ruolo); } diff --git a/scouthub-home-fe/Dockerfile b/scouthub-home-fe/Dockerfile index 9c8c34b..397edb9 100644 --- a/scouthub-home-fe/Dockerfile +++ b/scouthub-home-fe/Dockerfile @@ -3,6 +3,8 @@ FROM node:22-bookworm-slim AS build ARG KEYCLOAK_BASE_URL ARG ORG_SERVICE_API_BASE_URL ARG ATTIVITA_FE_BASE_URL +ARG MAGAZZINO_FE_BASE_URL +ARG EVENTI_FE_BASE_URL WORKDIR /app @@ -14,6 +16,8 @@ RUN sed -i \ -e "s#__KEYCLOAK_BASE_URL__#${KEYCLOAK_BASE_URL}#" \ -e "s#__ORG_SERVICE_API_BASE_URL__#${ORG_SERVICE_API_BASE_URL}#" \ -e "s#__ATTIVITA_FE_BASE_URL__#${ATTIVITA_FE_BASE_URL}#" \ + -e "s#__MAGAZZINO_FE_BASE_URL__#${MAGAZZINO_FE_BASE_URL}#" \ + -e "s#__EVENTI_FE_BASE_URL__#${EVENTI_FE_BASE_URL}#" \ src/environments/environment.ts RUN npm run build diff --git a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html index 54802c7..2db5301 100644 --- a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html +++ b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.html @@ -143,6 +143,7 @@ @if (canManageMembri) {
+

Invita per email

La persona invitata riceverà un link per completare l'accesso: se non ha ancora un account su Scouthub dovrà accedere o registrarsi al primo utilizzo del link, il flusso di accettazione dell'invito se ne diff --git a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts index d8ba82e..ddf3e34 100644 --- a/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts +++ b/scouthub-home-fe/src/app/gruppi/gruppo-dettaglio/gruppo-dettaglio.spec.ts @@ -402,12 +402,12 @@ describe('GruppoDettaglio', () => { expect(compiled.querySelector('.gruppo-dettaglio__richieste-ingresso')).toBeNull(); }); - it('mostra "Nessuna richiesta in attesa" se la lista è vuota', async () => { + it('mostra "Nessuna richiesta in sospeso" se la lista è vuota', async () => { await setup({ roles: ['admin'], richieste: [] }); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Nessuna richiesta in attesa'); + expect(compiled.textContent).toContain('Nessuna richiesta in sospeso'); }); it('mostra un errore se il caricamento fallisce', async () => { diff --git a/scouthub-home-fe/src/app/home/home.ts b/scouthub-home-fe/src/app/home/home.ts index f841c6d..2cee86b 100644 --- a/scouthub-home-fe/src/app/home/home.ts +++ b/scouthub-home-fe/src/app/home/home.ts @@ -22,6 +22,20 @@ const SERVIZI: ServizioCard[] = [ descrizione: 'Catalogo e ricerca delle attività scout', url: environment.attivitaFeBaseUrl, messaggioAccesso: 'Accedi per poter aggiungere attività' + }, + { + id: 'magazzino', + nome: 'Scouthub Magazzino', + descrizione: 'Gestione materiali, liste ed eventi del magazzino', + url: environment.magazzinoFeBaseUrl, + messaggioAccesso: 'Accedi per gestire il magazzino del tuo gruppo' + }, + { + id: 'eventi', + nome: 'Scouthub Eventi', + descrizione: 'Calendario delle attività ed eventi per branca', + url: environment.eventiFeBaseUrl, + messaggioAccesso: 'Accedi per gestire gli eventi del tuo gruppo' } ]; diff --git a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts index f0dd1f4..3ca4579 100644 --- a/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts +++ b/scouthub-home-fe/src/app/richieste-creazione-gruppo/richieste-creazione-gruppo.spec.ts @@ -69,12 +69,12 @@ describe('RichiesteCreazioneGruppo', () => { expect(compiled.textContent).toContain('Lazio'); }); - it('mostra "Nessuna richiesta in attesa" se la lista è vuota', async () => { + it('mostra "Nessuna richiesta in sospeso" se la lista è vuota', async () => { await setup({ roles: ['admin'], richieste: [] }); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Nessuna richiesta in attesa'); + expect(compiled.textContent).toContain('Nessuna richiesta in sospeso'); }); it('mostra un errore se il caricamento fallisce', async () => { diff --git a/scouthub-home-fe/src/environments/environment.development.ts b/scouthub-home-fe/src/environments/environment.development.ts index 8ad4e2f..ebb44ab 100644 --- a/scouthub-home-fe/src/environments/environment.development.ts +++ b/scouthub-home-fe/src/environments/environment.development.ts @@ -4,5 +4,7 @@ export const environment = { keycloakRealm: 'scouthub', keycloakClientId: 'scouthub-frontend', orgServiceApiBaseUrl: 'http://localhost:8000', - attivitaFeBaseUrl: 'http://localhost:7001' + attivitaFeBaseUrl: 'http://localhost:7001', + magazzinoFeBaseUrl: 'http://localhost:7002', + eventiFeBaseUrl: 'http://localhost:7003' }; diff --git a/scouthub-home-fe/src/environments/environment.ts b/scouthub-home-fe/src/environments/environment.ts index 5a44214..8635fe2 100644 --- a/scouthub-home-fe/src/environments/environment.ts +++ b/scouthub-home-fe/src/environments/environment.ts @@ -4,5 +4,7 @@ export const environment = { keycloakRealm: 'scouthub', keycloakClientId: 'scouthub-frontend', orgServiceApiBaseUrl: '__ORG_SERVICE_API_BASE_URL__', - attivitaFeBaseUrl: '__ATTIVITA_FE_BASE_URL__' + attivitaFeBaseUrl: '__ATTIVITA_FE_BASE_URL__', + magazzinoFeBaseUrl: '__MAGAZZINO_FE_BASE_URL__', + eventiFeBaseUrl: '__EVENTI_FE_BASE_URL__' };