Add scouthub-home-fe
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/mcp.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
/.vscode/
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist/scouthub-home-fe/browser /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# scouthub-home-fe
|
||||||
|
|
||||||
|
Frontend Angular per la gestione organizzativa di Scouthub (creazione gruppi, inviti),
|
||||||
|
affianca `scouthub-attivita-fe` nello stesso ecosistema. Parla con `scouthub-home-be`
|
||||||
|
per le API e con Keycloak per l'autenticazione.
|
||||||
|
|
||||||
|
Generato con Angular CLI 22.0.7 (vedi output `ng version` più sotto), componenti
|
||||||
|
standalone (nessun NgModule) e routing con lazy loading per feature.
|
||||||
|
|
||||||
|
## Stato del progetto
|
||||||
|
|
||||||
|
Implementati: modulo core di autenticazione (login OIDC via Keycloak, contesto
|
||||||
|
organizzazione, guard di routing), feature `crea-gruppo` (form di creazione gruppo
|
||||||
|
scout) e feature `inviti` (accettazione di un invito via link pubblico). Il tema
|
||||||
|
Angular Material è configurato. Non c'è ancora una vera home/dashboard applicativa
|
||||||
|
(il redirect post-successo va su `/`, che al momento non ha un componente associato).
|
||||||
|
|
||||||
|
## Struttura cartelle
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
core/ servizi condivisi
|
||||||
|
auth/
|
||||||
|
keycloak.provider.ts provideKeycloakAngular() — login OIDC Authorization Code + PKCE
|
||||||
|
auth.guard.ts authGuard — richiede login, poi organization (non usato in routing
|
||||||
|
al momento: nessuna rotta protetta è ancora stata definita)
|
||||||
|
organization-context.service.ts OrganizationContextService — legge il claim "organization" dal token
|
||||||
|
crea-gruppo/ feature "crea gruppo scout" (/crea-gruppo, lazy-loaded, pubblica: è la
|
||||||
|
destinazione di chi è autenticato ma senza organization)
|
||||||
|
inviti/ feature "accetta invito" (/inviti/:token, lazy-loaded, pubblica: un invito
|
||||||
|
deve essere visualizzabile anche da chi non ha ancora un account)
|
||||||
|
src/environments/ environment.ts / environment.development.ts
|
||||||
|
public/
|
||||||
|
silent-check-sso.html richiesto dal flusso check-sso silenzioso di Keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modulo di autenticazione
|
||||||
|
|
||||||
|
- **Login**: `provideKeycloak` (in `keycloak.provider.ts`) usa il client pubblico
|
||||||
|
`scouthub-frontend` con Authorization Code Flow + PKCE (comportamento di default di
|
||||||
|
`keycloak-js` per i public client con `standardFlowEnabled: true`), `onLoad: 'check-sso'`
|
||||||
|
e refresh automatico del token via `withAutoRefreshToken`.
|
||||||
|
- **OrganizationContextService** (`core/organization-context.service.ts`): espone
|
||||||
|
`hasOrganization` / `currentOrganizationId` / `currentOrganizationName` come `Observable`,
|
||||||
|
derivati dal claim `organization` del token (client scope built-in di Keycloak
|
||||||
|
Organizations). Se l'utente appartiene a più Organization, la scelta è già stata fatta
|
||||||
|
da Keycloak durante il login: la SPA vede sempre una sola membership nel token.
|
||||||
|
- **authGuard** (`core/auth/auth.guard.ts`): se non autenticato avvia `keycloak.login()`;
|
||||||
|
se autenticato ma senza organization reindirizza a `/crea-gruppo`; altrimenti lascia
|
||||||
|
proseguire. Non è ancora applicato a nessuna rotta (`crea-gruppo` e `inviti/:token`
|
||||||
|
sono entrambe pubbliche per costruzione): va aggiunto quando saranno introdotte
|
||||||
|
rotte riservate a chi ha già una organization.
|
||||||
|
|
||||||
|
### Feature `crea-gruppo` (`/crea-gruppo`)
|
||||||
|
|
||||||
|
Form a singolo campo ("Nome del gruppo scout", 3-100 caratteri) che chiama
|
||||||
|
`POST {orgServiceApiBaseUrl}/gruppi`. Un 409 (nome già esistente) mostra il messaggio
|
||||||
|
del backend e permette di correggere il nome senza reload. Al successo, forza un
|
||||||
|
refresh del token (`keycloak.updateToken(-1)`, incondizionato: il token corrente non
|
||||||
|
contiene ancora il nuovo claim `organization`) e poi naviga a `/`.
|
||||||
|
|
||||||
|
### Feature `inviti` (`/inviti/:token`)
|
||||||
|
|
||||||
|
Rotta pubblica (nessuna autenticazione richiesta per visualizzarla). Al caricamento
|
||||||
|
chiama `GET {orgServiceApiBaseUrl}/inviti/:token` (endpoint pubblico) e mostra nome
|
||||||
|
gruppo, ruolo offerto e validità. Se l'invito non è valido (scaduto o già accettato)
|
||||||
|
non mostra alcuna azione. Se valido, il pulsante "Accetta invito":
|
||||||
|
|
||||||
|
- se l'utente non è autenticato, avvia `keycloak.login({ redirectUri: window.location.href })`
|
||||||
|
— dopo login/registrazione l'utente torna sulla stessa pagina di invito;
|
||||||
|
- se autenticato, chiama `POST {orgServiceApiBaseUrl}/inviti/:token/accetta`, forza il
|
||||||
|
refresh del token e naviga a `/`. Errori (409 già accettato, 410 scaduto, 403 email
|
||||||
|
non corrispondente) mostrano un messaggio dedicato senza reload.
|
||||||
|
|
||||||
|
## Prerequisiti
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- I servizi dell'ecosistema Scouthub in esecuzione: Keycloak e `scouthub-home-be`
|
||||||
|
(vedi `docker-compose.yml` e `scouthub-home-be/README.md` nella root del repo)
|
||||||
|
|
||||||
|
## Configurazione ambiente
|
||||||
|
|
||||||
|
`src/environments/environment.ts` (produzione) e `environment.development.ts` (dev,
|
||||||
|
usato automaticamente da `ng serve`/`ng build --configuration development`)
|
||||||
|
espongono:
|
||||||
|
|
||||||
|
- `keycloakBaseUrl` — URL base dell'istanza Keycloak (default `http://localhost:8081`,
|
||||||
|
coerente con `KEYCLOAK_PORT` in `.env` alla root del repo)
|
||||||
|
- `keycloakRealm` — realm Keycloak (`scouthub`)
|
||||||
|
- `keycloakClientId` — client pubblico già definito in `keycloak/realm-export.json`
|
||||||
|
(`scouthub-frontend`)
|
||||||
|
- `orgServiceApiBaseUrl` — URL base di `scouthub-home-be` (default `http://localhost:8082`)
|
||||||
|
|
||||||
|
## Collegare Keycloak in locale
|
||||||
|
|
||||||
|
1. Avviare Keycloak (e Postgres) dalla root del repo:
|
||||||
|
```bash
|
||||||
|
docker compose up -d keycloak-db keycloak
|
||||||
|
```
|
||||||
|
Il realm `scouthub` viene importato automaticamente da `keycloak/realm-export.json`
|
||||||
|
al primo avvio (`--import-realm`).
|
||||||
|
2. Il client pubblico `scouthub-frontend` ha `redirectUris`/`webOrigins` che includono
|
||||||
|
sia `http://localhost:4200` (usato da `scouthub-attivita-fe`) sia
|
||||||
|
`http://localhost:4201` (porta di default di questo progetto, vedi sotto) — se si
|
||||||
|
cambia porta, aggiornare `keycloak/realm-export.json` di conseguenza e reimportare
|
||||||
|
il realm (o aggiornarlo da console admin Keycloak).
|
||||||
|
3. `provideKeycloakAngular()` (`src/app/core/auth/keycloak.provider.ts`) inizializza
|
||||||
|
il client con `onLoad: 'check-sso'`: all'avvio l'app verifica in un iframe nascosto
|
||||||
|
se esiste già una sessione Keycloak, senza forzare un redirect immediato per le
|
||||||
|
pagine pubbliche.
|
||||||
|
|
||||||
|
## Collegare scouthub-home-be in locale
|
||||||
|
|
||||||
|
1. Avviare `scouthub-home-be` seguendo il suo README (`npm run dev`, porta di default
|
||||||
|
`8082`).
|
||||||
|
2. Le richieste verso `orgServiceApiBaseUrl` ricevono automaticamente l'header
|
||||||
|
`Authorization: Bearer <token>` tramite l'interceptor `includeBearerTokenInterceptor`
|
||||||
|
di `keycloak-angular`, configurato in `app.config.ts`.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
Questo progetto usa la porta `4201` (per non collidere con `scouthub-attivita-fe`,
|
||||||
|
che gira sulla `4200`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Apri il browser su `http://localhost:4201/`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Artefatti di build in `dist/scouthub-home-fe`.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
Esegue gli unit test con Vitest.
|
||||||
|
|
||||||
|
## Versione Angular CLI usata per lo scaffold
|
||||||
|
|
||||||
|
```
|
||||||
|
Angular CLI : 22.0.7
|
||||||
|
Angular : 22.0.8
|
||||||
|
Node.js : 24.16.0
|
||||||
|
Package Manager : npm 11.12.0
|
||||||
|
Operating System : win32 x64
|
||||||
|
```
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"scouthub-home-fe": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/material-theme.scss", "src/styles.css"]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"port": 4201
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "scouthub-home-fe:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "scouthub-home-fe:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8767
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "scouthub-home-fe",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@11.12.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^22.0.6",
|
||||||
|
"@angular/common": "^22.0.0",
|
||||||
|
"@angular/compiler": "^22.0.0",
|
||||||
|
"@angular/core": "^22.0.0",
|
||||||
|
"@angular/forms": "^22.0.0",
|
||||||
|
"@angular/material": "^22.0.6",
|
||||||
|
"@angular/platform-browser": "^22.0.0",
|
||||||
|
"@angular/router": "^22.0.0",
|
||||||
|
"keycloak-angular": "^22.0.0",
|
||||||
|
"keycloak-js": "^26.2.4",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^22.0.7",
|
||||||
|
"@angular/cli": "^22.0.7",
|
||||||
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
parent.postMessage(location.href, location.origin);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
createInterceptorCondition,
|
||||||
|
IncludeBearerTokenCondition,
|
||||||
|
includeBearerTokenInterceptor,
|
||||||
|
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG
|
||||||
|
} from 'keycloak-angular';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { provideKeycloakAngular } from './core/auth/keycloak.provider';
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
|
const escapedOrgServiceBaseUrl = environment.orgServiceApiBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const orgServiceBearerCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
|
||||||
|
urlPattern: new RegExp(`^${escapedOrgServiceBaseUrl}(/.*)?$`, 'i'),
|
||||||
|
bearerPrefix: 'Bearer'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideKeycloakAngular(),
|
||||||
|
{
|
||||||
|
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
|
||||||
|
useValue: [orgServiceBearerCondition]
|
||||||
|
},
|
||||||
|
provideHttpClient(withInterceptors([includeBearerTokenInterceptor]))
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet />
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { authGuard } from './core/auth/auth.guard';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./home/home').then((m) => m.Home),
|
||||||
|
canActivate: [authGuard]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'crea-gruppo',
|
||||||
|
loadChildren: () => import('./crea-gruppo/crea-gruppo.routes').then((m) => m.CREA_GRUPPO_ROUTES)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Pubblica: un invito deve poter essere visualizzato anche da chi non ha ancora un account.
|
||||||
|
path: 'inviti',
|
||||||
|
loadChildren: () => import('./inviti/inviti.routes').then((m) => m.INVITI_ROUTES)
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [App],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css'
|
||||||
|
})
|
||||||
|
export class App {}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRouteSnapshot, provideRouter, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
|
import { authGuard } from './auth.guard';
|
||||||
|
|
||||||
|
const route = {} as ActivatedRouteSnapshot;
|
||||||
|
const state = { url: '/inviti' } as RouterStateSnapshot;
|
||||||
|
|
||||||
|
function setup(keycloakMock: Partial<Keycloak>): void {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideRouter([]), { provide: Keycloak, useValue: keycloakMock }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('authGuard', () => {
|
||||||
|
it('avvia il login se l\'utente non è autenticato e non fa proseguire la navigazione', async () => {
|
||||||
|
const login = vi.fn().mockResolvedValue(undefined);
|
||||||
|
setup({ authenticated: false, login });
|
||||||
|
|
||||||
|
const result = await TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||||
|
|
||||||
|
expect(login).toHaveBeenCalledWith({ redirectUri: window.location.origin + '/inviti' });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reindirizza a /crea-gruppo se l\'utente è autenticato ma senza organization', async () => {
|
||||||
|
setup({ authenticated: true, tokenParsed: {} });
|
||||||
|
|
||||||
|
const result = await TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||||
|
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
expect((result as UrlTree).toString()).toBe(router.parseUrl('/crea-gruppo').toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lascia proseguire la navigazione se l\'utente è autenticato e ha una organization', async () => {
|
||||||
|
setup({
|
||||||
|
authenticated: true,
|
||||||
|
tokenParsed: {
|
||||||
|
organization: {
|
||||||
|
'gruppo-scout-milano-1': { id: 'org-123' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import { AuthGuardData, createAuthGuard } from 'keycloak-angular';
|
||||||
|
|
||||||
|
import { extractOrganizationFromToken } from '../organization-context.service';
|
||||||
|
|
||||||
|
export async function isAccessAllowed(
|
||||||
|
_route: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot,
|
||||||
|
authData: AuthGuardData
|
||||||
|
): Promise<boolean | UrlTree> {
|
||||||
|
const router = inject(Router);
|
||||||
|
const { authenticated, keycloak } = authData;
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
await keycloak.login({ redirectUri: window.location.origin + state.url });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOrganization = extractOrganizationFromToken(keycloak.tokenParsed) !== null;
|
||||||
|
return hasOrganization ? true : router.parseUrl('/crea-gruppo');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAccessAllowed);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { EnvironmentProviders } from '@angular/core';
|
||||||
|
import { provideKeycloak, withAutoRefreshToken, AutoRefreshTokenService, UserActivityService } from 'keycloak-angular';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
export function provideKeycloakAngular(): EnvironmentProviders {
|
||||||
|
return provideKeycloak({
|
||||||
|
config: {
|
||||||
|
url: environment.keycloakBaseUrl,
|
||||||
|
realm: environment.keycloakRealm,
|
||||||
|
clientId: environment.keycloakClientId
|
||||||
|
},
|
||||||
|
initOptions: {
|
||||||
|
onLoad: 'check-sso',
|
||||||
|
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
|
||||||
|
redirectUri: window.location.origin + '/'
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
withAutoRefreshToken({
|
||||||
|
onInactivityTimeout: 'logout',
|
||||||
|
sessionTimeout: 300000
|
||||||
|
})
|
||||||
|
],
|
||||||
|
providers: [AutoRefreshTokenService, UserActivityService]
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { KEYCLOAK_EVENT_SIGNAL, KeycloakEvent, KeycloakEventType } from 'keycloak-angular';
|
||||||
|
|
||||||
|
import { OrganizationContextService } from './organization-context.service';
|
||||||
|
|
||||||
|
function setup(keycloakMock: Partial<Keycloak>): OrganizationContextService {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: Keycloak, useValue: keycloakMock },
|
||||||
|
{
|
||||||
|
provide: KEYCLOAK_EVENT_SIGNAL,
|
||||||
|
useValue: signal<KeycloakEvent>({ type: KeycloakEventType.Ready, args: true })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(OrganizationContextService);
|
||||||
|
// toObservable propaga il valore del signal tramite un effect: va sincronizzato
|
||||||
|
// esplicitamente in test, non essendoci un ciclo di change detection reale.
|
||||||
|
TestBed.tick();
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OrganizationContextService', () => {
|
||||||
|
it('espone id e nome della organization quando il claim "organization" è presente nel token', async () => {
|
||||||
|
const service = setup({
|
||||||
|
authenticated: true,
|
||||||
|
tokenParsed: {
|
||||||
|
organization: {
|
||||||
|
'gruppo-scout-milano-1': { id: 'org-123' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await firstValueFrom(service.hasOrganization)).toBe(true);
|
||||||
|
expect(await firstValueFrom(service.currentOrganizationId)).toBe('org-123');
|
||||||
|
expect(await firstValueFrom(service.currentOrganizationName)).toBe('gruppo-scout-milano-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('usa l\'alias come id/nome quando il claim non porta un "id" esplicito', async () => {
|
||||||
|
const service = setup({
|
||||||
|
authenticated: true,
|
||||||
|
tokenParsed: {
|
||||||
|
organization: {
|
||||||
|
'gruppo-scout-milano-1': {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await firstValueFrom(service.currentOrganizationId)).toBe('gruppo-scout-milano-1');
|
||||||
|
expect(await firstValueFrom(service.currentOrganizationName)).toBe('gruppo-scout-milano-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non espone alcuna organization quando il claim è assente dal token', async () => {
|
||||||
|
const service = setup({
|
||||||
|
authenticated: true,
|
||||||
|
tokenParsed: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await firstValueFrom(service.hasOrganization)).toBe(false);
|
||||||
|
expect(await firstValueFrom(service.currentOrganizationId)).toBeNull();
|
||||||
|
expect(await firstValueFrom(service.currentOrganizationName)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non espone alcuna organization se l\'utente non è autenticato, anche con claim presente', async () => {
|
||||||
|
const service = setup({
|
||||||
|
authenticated: false,
|
||||||
|
tokenParsed: {
|
||||||
|
organization: {
|
||||||
|
'gruppo-scout-milano-1': { id: 'org-123' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await firstValueFrom(service.hasOrganization)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import Keycloak, { KeycloakTokenParsed } from 'keycloak-js';
|
||||||
|
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
|
||||||
|
|
||||||
|
export interface OrganizationInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrganizationClaimEntry {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il claim "organization" (client scope built-in di Keycloak Organizations) ha la forma
|
||||||
|
* `{ [aliasOrganizzazione]: { id?, name? } }`: la SPA riceve un solo membership perché la
|
||||||
|
* scelta tra più Organization è già stata fatta dall'utente nella schermata nativa di Keycloak.
|
||||||
|
*/
|
||||||
|
export function extractOrganizationFromToken(tokenParsed: KeycloakTokenParsed | undefined): OrganizationInfo | null {
|
||||||
|
const claim = tokenParsed?.['organization'] as Record<string, OrganizationClaimEntry> | undefined;
|
||||||
|
if (!claim) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [alias, entry] = Object.entries(claim)[0] ?? [];
|
||||||
|
if (!alias) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry?.id ?? alias,
|
||||||
|
name: entry?.name ?? alias
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class OrganizationContextService {
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
private readonly keycloakEvent = toObservable(inject(KEYCLOAK_EVENT_SIGNAL));
|
||||||
|
|
||||||
|
private readonly organization$: Observable<OrganizationInfo | null> = this.keycloakEvent.pipe(
|
||||||
|
map(() => (this.keycloak.authenticated ? extractOrganizationFromToken(this.keycloak.tokenParsed) : null))
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly hasOrganization: Observable<boolean> = this.organization$.pipe(map((organization) => organization !== null));
|
||||||
|
|
||||||
|
readonly currentOrganizationId: Observable<string | null> = this.organization$.pipe(
|
||||||
|
map((organization) => organization?.id ?? null)
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly currentOrganizationName: Observable<string | null> = this.organization$.pipe(
|
||||||
|
map((organization) => organization?.name ?? null)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRouteSnapshot, provideRouter, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
|
import { requireAuthGuard } from './require-auth.guard';
|
||||||
|
|
||||||
|
const route = {} as ActivatedRouteSnapshot;
|
||||||
|
const state = { url: '/crea-gruppo' } as RouterStateSnapshot;
|
||||||
|
|
||||||
|
function setup(keycloakMock: Partial<Keycloak>): void {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideRouter([]), { provide: Keycloak, useValue: keycloakMock }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requireAuthGuard', () => {
|
||||||
|
it('avvia il login se l\'utente non è autenticato e non fa proseguire la navigazione', async () => {
|
||||||
|
const login = vi.fn().mockResolvedValue(undefined);
|
||||||
|
setup({ authenticated: false, login });
|
||||||
|
|
||||||
|
const result = await TestBed.runInInjectionContext(() => requireAuthGuard(route, state));
|
||||||
|
|
||||||
|
expect(login).toHaveBeenCalledWith({ redirectUri: window.location.origin + '/crea-gruppo' });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lascia proseguire la navigazione se l\'utente è autenticato, senza controllare l\'organization', async () => {
|
||||||
|
setup({ authenticated: true, tokenParsed: {} });
|
||||||
|
|
||||||
|
const result = await TestBed.runInInjectionContext(() => requireAuthGuard(route, state));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import { AuthGuardData, createAuthGuard } from 'keycloak-angular';
|
||||||
|
|
||||||
|
export async function isAuthenticated(
|
||||||
|
_route: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot,
|
||||||
|
authData: AuthGuardData
|
||||||
|
): Promise<boolean | UrlTree> {
|
||||||
|
const { authenticated, keycloak } = authData;
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
await keycloak.login({ redirectUri: window.location.origin + state.url });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireAuthGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAuthenticated);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.crea-gruppo {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crea-gruppo__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crea-gruppo__field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crea-gruppo__error {
|
||||||
|
color: var(--mat-sys-error, #b3261e);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<section class="crea-gruppo">
|
||||||
|
<h1>Crea un nuovo gruppo scout</h1>
|
||||||
|
<p>Il tuo account non è ancora collegato a nessun gruppo scout: creane uno per continuare.</p>
|
||||||
|
|
||||||
|
<form class="crea-gruppo__form" (submit)="$event.preventDefault(); submit()" novalidate>
|
||||||
|
<mat-form-field appearance="outline" class="crea-gruppo__field">
|
||||||
|
<mat-label>Nome del gruppo scout</mat-label>
|
||||||
|
<input matInput [formControl]="nome" placeholder="Es. Agesci Milano 1" />
|
||||||
|
@if (nome.hasError('required')) {
|
||||||
|
<mat-error>Il nome del gruppo è obbligatorio.</mat-error>
|
||||||
|
} @else if (nome.hasError('minlength')) {
|
||||||
|
<mat-error>Il nome deve avere almeno 3 caratteri.</mat-error>
|
||||||
|
} @else if (nome.hasError('maxlength')) {
|
||||||
|
<mat-error>Il nome non può superare i 100 caratteri.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (errorMessage(); as message) {
|
||||||
|
<p class="crea-gruppo__error" role="alert">{{ message }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" type="submit" [disabled]="submitting()">
|
||||||
|
{{ submitting() ? 'Creazione in corso…' : 'Crea gruppo' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { requireAuthGuard } from '../core/require-auth.guard';
|
||||||
|
|
||||||
|
export const CREA_GRUPPO_ROUTES: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./crea-gruppo').then((m) => m.CreaGruppo),
|
||||||
|
canActivate: [requireAuthGuard]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
|
import { CreaGruppo } from './crea-gruppo';
|
||||||
|
import { GruppiApiService } from './gruppi-api.service';
|
||||||
|
|
||||||
|
describe('CreaGruppo', () => {
|
||||||
|
let component: CreaGruppo;
|
||||||
|
let fixture: ComponentFixture<CreaGruppo>;
|
||||||
|
let gruppiApi: { creaGruppo: ReturnType<typeof vi.fn> };
|
||||||
|
let keycloak: { updateToken: ReturnType<typeof vi.fn> };
|
||||||
|
let router: { navigateByUrl: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
gruppiApi = { creaGruppo: vi.fn() };
|
||||||
|
keycloak = { updateToken: vi.fn().mockResolvedValue(true) };
|
||||||
|
router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CreaGruppo],
|
||||||
|
providers: [
|
||||||
|
{ provide: GruppiApiService, useValue: gruppiApi },
|
||||||
|
{ provide: Keycloak, useValue: keycloak },
|
||||||
|
{ provide: Router, useValue: router }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CreaGruppo);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('si crea correttamente', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validazione del campo nome', () => {
|
||||||
|
it('è invalido se vuoto (required)', () => {
|
||||||
|
component.nome.setValue('');
|
||||||
|
expect(component.nome.hasError('required')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('è invalido con meno di 3 caratteri', () => {
|
||||||
|
component.nome.setValue('Ab');
|
||||||
|
expect(component.nome.hasError('minlength')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('è valido con esattamente 3 caratteri', () => {
|
||||||
|
component.nome.setValue('Ab1');
|
||||||
|
expect(component.nome.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('è invalido oltre i 100 caratteri', () => {
|
||||||
|
component.nome.setValue('a'.repeat(101));
|
||||||
|
expect(component.nome.hasError('maxlength')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('è valido con esattamente 100 caratteri', () => {
|
||||||
|
component.nome.setValue('a'.repeat(100));
|
||||||
|
expect(component.nome.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('è valido con un nome tipico', () => {
|
||||||
|
component.nome.setValue('Agesci Milano 1');
|
||||||
|
expect(component.nome.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non chiama l\'API e marca il controllo come touched se il nome non è valido', async () => {
|
||||||
|
component.nome.setValue('');
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(gruppiApi.creaGruppo).not.toHaveBeenCalled();
|
||||||
|
expect(component.nome.touched).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gestione errore 409 (nome già esistente)', () => {
|
||||||
|
it('mostra il messaggio del backend e permette di correggere il nome senza ricaricare la pagina', async () => {
|
||||||
|
component.nome.setValue('Agesci Milano 1');
|
||||||
|
gruppiApi.creaGruppo.mockReturnValue(
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 409,
|
||||||
|
error: { message: 'Esiste già un gruppo scout con nome "Agesci Milano 1"' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(component.errorMessage()).toBe('Esiste già un gruppo scout con nome "Agesci Milano 1"');
|
||||||
|
expect(component.submitting()).toBe(false);
|
||||||
|
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// il form resta utilizzabile: si può correggere il nome e ritentare senza reload
|
||||||
|
component.nome.setValue('Agesci Milano 2');
|
||||||
|
expect(component.nome.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un messaggio generico per errori diversi da 409', async () => {
|
||||||
|
component.nome.setValue('Agesci Milano 1');
|
||||||
|
gruppiApi.creaGruppo.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 })));
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(component.errorMessage()).toBe('Si è verificato un errore imprevisto. Riprova.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('redirect dopo il successo', () => {
|
||||||
|
it('aggiorna forzatamente il token e reindirizza alla home dopo la creazione del gruppo', async () => {
|
||||||
|
component.nome.setValue('Agesci Milano 1');
|
||||||
|
gruppiApi.creaGruppo.mockReturnValue(of({ orgId: 'org-1', gruppiCreati: ['Capi'] }));
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith('Agesci Milano 1');
|
||||||
|
expect(keycloak.updateToken).toHaveBeenCalledWith(-1);
|
||||||
|
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
|
||||||
|
expect(component.submitting()).toBe(false);
|
||||||
|
expect(component.errorMessage()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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 Keycloak from 'keycloak-js';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { GruppiApiService } from './gruppi-api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-crea-gruppo',
|
||||||
|
imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatProgressSpinnerModule],
|
||||||
|
templateUrl: './crea-gruppo.html',
|
||||||
|
styleUrl: './crea-gruppo.css'
|
||||||
|
})
|
||||||
|
export class CreaGruppo {
|
||||||
|
private readonly gruppiApi = inject(GruppiApiService);
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly nome = new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)]
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly submitting = signal(false);
|
||||||
|
readonly errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
async submit(): Promise<void> {
|
||||||
|
if (this.submitting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.nome.invalid) {
|
||||||
|
this.nome.markAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
this.submitting.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.gruppiApi.creaGruppo(this.nome.value.trim()));
|
||||||
|
// Il gruppo è appena stato creato su Keycloak: il token corrente non contiene ancora
|
||||||
|
// il claim "organization" aggiornato, va quindi forzato un refresh prima di navigare.
|
||||||
|
await this.keycloak.updateToken(-1).catch(() => undefined);
|
||||||
|
this.submitting.set(false);
|
||||||
|
await this.router.navigateByUrl('/');
|
||||||
|
} catch (error) {
|
||||||
|
this.submitting.set(false);
|
||||||
|
this.handleError(error as HttpErrorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: HttpErrorResponse): void {
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.errorMessage.set(error.error?.message ?? 'Esiste già un gruppo scout con questo nome.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.errorMessage.set('Si è verificato un errore imprevisto. Riprova.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
export interface CreaGruppoResponse {
|
||||||
|
orgId: string;
|
||||||
|
gruppiCreati: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class GruppiApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
creaGruppo(nome: string): Observable<CreaGruppoResponse> {
|
||||||
|
return this.http.post<CreaGruppoResponse>(`${environment.orgServiceApiBaseUrl}/gruppi`, { nome });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servizi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servizio-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servizio-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.servizio-card__titolo {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.servizio-card__descrizione {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<section class="dashboard">
|
||||||
|
<h1>{{ organizationName() ?? 'Il tuo gruppo scout' }}</h1>
|
||||||
|
|
||||||
|
<section class="servizi">
|
||||||
|
<h2>Servizi</h2>
|
||||||
|
<a class="servizio-card" [href]="attivitaAppBaseUrl">
|
||||||
|
<span class="servizio-card__titolo">Attività</span>
|
||||||
|
<span class="servizio-card__descrizione">Catalogo e ricerca delle attività scout</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button mat-stroked-button type="button" (click)="logout()">Esci</button>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import { OrganizationContextService } from '../core/organization-context.service';
|
||||||
|
import { Home } from './home';
|
||||||
|
|
||||||
|
describe('Home', () => {
|
||||||
|
let component: Home;
|
||||||
|
let fixture: ComponentFixture<Home>;
|
||||||
|
let organizationContext: { currentOrganizationName: Observable<string | null> };
|
||||||
|
let keycloak: { logout: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
async function setup(): Promise<void> {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Home],
|
||||||
|
providers: [
|
||||||
|
{ provide: OrganizationContextService, useValue: organizationContext },
|
||||||
|
{ provide: Keycloak, useValue: keycloak }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Home);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
organizationContext = { currentOrganizationName: of('Agesci Milano 1') };
|
||||||
|
keycloak = { logout: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('si crea correttamente', async () => {
|
||||||
|
await setup();
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra il nome del gruppo scout corrente', async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Agesci Milano 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra il link verso l\'app Attività puntando a attivitaAppBaseUrl', async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const link = compiled.querySelector('a.servizio-card') as HTMLAnchorElement;
|
||||||
|
expect(link).toBeTruthy();
|
||||||
|
expect(link.getAttribute('href')).toBe(environment.attivitaAppBaseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invoca il logout di Keycloak al click sul pulsante "Esci"', async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const button = compiled.querySelector('button') as HTMLButtonElement;
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
expect(keycloak.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin + '/' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import { OrganizationContextService } from '../core/organization-context.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
imports: [MatButtonModule],
|
||||||
|
templateUrl: './home.html',
|
||||||
|
styleUrl: './home.css'
|
||||||
|
})
|
||||||
|
export class Home {
|
||||||
|
private readonly organizationContext = inject(OrganizationContextService);
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
|
||||||
|
readonly organizationName = toSignal(this.organizationContext.currentOrganizationName, { initialValue: null });
|
||||||
|
readonly attivitaAppBaseUrl = environment.attivitaAppBaseUrl;
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
export interface InvitoPubblico {
|
||||||
|
email: string;
|
||||||
|
nomeGruppo: string;
|
||||||
|
ruolo: string;
|
||||||
|
valido: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccettaInvitoResult {
|
||||||
|
organizationId: string;
|
||||||
|
ruolo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class InvitiApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
getInvito(token: string): Observable<InvitoPubblico> {
|
||||||
|
return this.http.get<InvitoPubblico>(`${environment.orgServiceApiBaseUrl}/inviti/${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
accettaInvito(token: string): Observable<AccettaInvitoResult> {
|
||||||
|
return this.http.post<AccettaInvitoResult>(`${environment.orgServiceApiBaseUrl}/inviti/${token}/accetta`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.inviti {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviti__error {
|
||||||
|
color: var(--mat-sys-error, #b3261e);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<section class="inviti">
|
||||||
|
@if (loading()) {
|
||||||
|
<p>Caricamento invito…</p>
|
||||||
|
} @else if (loadError(); as message) {
|
||||||
|
<p class="inviti__error" role="alert">{{ message }}</p>
|
||||||
|
} @else if (invito(); as invitoData) {
|
||||||
|
<h1>Invito al gruppo scout {{ invitoData.nomeGruppo }}</h1>
|
||||||
|
<p>Ruolo offerto: <strong>{{ invitoData.ruolo }}</strong></p>
|
||||||
|
|
||||||
|
@if (!invitoData.valido) {
|
||||||
|
<p class="inviti__error" role="alert">
|
||||||
|
Questo invito non è più valido: potrebbe essere scaduto o già stato accettato.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
@if (acceptError(); as errMessage) {
|
||||||
|
<p class="inviti__error" role="alert">{{ errMessage }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" (click)="accetta()" [disabled]="accepting()">
|
||||||
|
{{ accepting() ? 'Accettazione in corso…' : 'Accetta invito' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const INVITI_ROUTES: Routes = [
|
||||||
|
{
|
||||||
|
path: ':token',
|
||||||
|
loadComponent: () => import('./inviti').then((m) => m.Inviti)
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
|
||||||
|
import { Inviti } from './inviti';
|
||||||
|
import { InvitiApiService, InvitoPubblico } from './inviti-api.service';
|
||||||
|
|
||||||
|
describe('Inviti', () => {
|
||||||
|
let component: Inviti;
|
||||||
|
let fixture: ComponentFixture<Inviti>;
|
||||||
|
let invitiApi: { getInvito: ReturnType<typeof vi.fn>; accettaInvito: ReturnType<typeof vi.fn> };
|
||||||
|
let keycloak: { authenticated: boolean; login: ReturnType<typeof vi.fn>; updateToken: ReturnType<typeof vi.fn> };
|
||||||
|
let router: { navigateByUrl: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const invitoValido: InvitoPubblico = {
|
||||||
|
email: 'mario.rossi@example.com',
|
||||||
|
nomeGruppo: 'Agesci Milano 1',
|
||||||
|
ruolo: 'Capi',
|
||||||
|
valido: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Il componente viene creato senza far girare la change detection di Angular: ngOnInit
|
||||||
|
// viene invocato e atteso manualmente una sola volta, per uno stato deterministico
|
||||||
|
// (evita che l'auto-init di Angular alla prima detectChanges() esegua un secondo
|
||||||
|
// caricamento asincrono in parallelo a quello già atteso qui).
|
||||||
|
async function setup(): Promise<void> {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Inviti],
|
||||||
|
providers: [
|
||||||
|
{ provide: InvitiApiService, useValue: invitiApi },
|
||||||
|
{ provide: Keycloak, useValue: keycloak },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { snapshot: { paramMap: convertToParamMap({ token: 'token-abc' }) } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Inviti);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
invitiApi = { getInvito: vi.fn(), accettaInvito: vi.fn() };
|
||||||
|
keycloak = {
|
||||||
|
authenticated: false,
|
||||||
|
login: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateToken: vi.fn().mockResolvedValue(true)
|
||||||
|
};
|
||||||
|
router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carica e mostra i dettagli di un invito valido, pronto per essere accettato', async () => {
|
||||||
|
invitiApi.getInvito.mockReturnValue(of(invitoValido));
|
||||||
|
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
expect(invitiApi.getInvito).toHaveBeenCalledWith('token-abc');
|
||||||
|
expect(component.loading()).toBe(false);
|
||||||
|
expect(component.loadError()).toBeNull();
|
||||||
|
expect(component.invito()).toEqual(invitoValido);
|
||||||
|
expect(component.invito()?.valido).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carica un invito non valido (scaduto o già accettato): nessuna azione possibile', async () => {
|
||||||
|
invitiApi.getInvito.mockReturnValue(of({ ...invitoValido, valido: false }));
|
||||||
|
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
expect(component.invito()?.valido).toBe(false);
|
||||||
|
expect(component.loadError()).toBeNull();
|
||||||
|
|
||||||
|
// il template nasconde il pulsante "Accetta invito" quando invito().valido è false;
|
||||||
|
// a livello di componente verifichiamo che accettare non abbia comunque effetto.
|
||||||
|
await component.accetta();
|
||||||
|
expect(invitiApi.accettaInvito).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un messaggio quando l\'invito non viene trovato (404)', async () => {
|
||||||
|
invitiApi.getInvito.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 404 })));
|
||||||
|
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
expect(component.loadError()).toBe('Invito non trovato: verifica il link ricevuto.');
|
||||||
|
expect(component.invito()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avvia login/registrazione Keycloak se l'utente non è autenticato e clicca \"Accetta invito\"", async () => {
|
||||||
|
invitiApi.getInvito.mockReturnValue(of(invitoValido));
|
||||||
|
keycloak.authenticated = false;
|
||||||
|
|
||||||
|
await setup();
|
||||||
|
await component.accetta();
|
||||||
|
|
||||||
|
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
|
||||||
|
expect(invitiApi.accettaInvito).not.toHaveBeenCalled();
|
||||||
|
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accetta l'invito e reindirizza alla home se l'utente è autenticato", async () => {
|
||||||
|
invitiApi.getInvito.mockReturnValue(of(invitoValido));
|
||||||
|
invitiApi.accettaInvito.mockReturnValue(of({ organizationId: 'org-1', ruolo: 'Capi' }));
|
||||||
|
keycloak.authenticated = true;
|
||||||
|
|
||||||
|
await setup();
|
||||||
|
await component.accetta();
|
||||||
|
|
||||||
|
expect(invitiApi.accettaInvito).toHaveBeenCalledWith('token-abc');
|
||||||
|
// il token corrente non contiene ancora il claim "organization": va aggiornato prima del redirect.
|
||||||
|
expect(keycloak.updateToken).toHaveBeenCalledWith(-1);
|
||||||
|
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
|
||||||
|
expect(component.accepting()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mostra un messaggio se l'accettazione fallisce e permette di ritentare senza ricaricare la pagina", async () => {
|
||||||
|
invitiApi.getInvito.mockReturnValue(of(invitoValido));
|
||||||
|
invitiApi.accettaInvito.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409 })));
|
||||||
|
keycloak.authenticated = true;
|
||||||
|
|
||||||
|
await setup();
|
||||||
|
await component.accetta();
|
||||||
|
|
||||||
|
expect(component.acceptError()).toBe("L'invito è già stato accettato.");
|
||||||
|
expect(component.accepting()).toBe(false);
|
||||||
|
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { InvitiApiService, InvitoPubblico } from './inviti-api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-inviti',
|
||||||
|
imports: [MatButtonModule],
|
||||||
|
templateUrl: './inviti.html',
|
||||||
|
styleUrl: './inviti.css'
|
||||||
|
})
|
||||||
|
export class Inviti implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly invitiApi = inject(InvitiApiService);
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly token = this.route.snapshot.paramMap.get('token') ?? '';
|
||||||
|
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly loadError = signal<string | null>(null);
|
||||||
|
readonly invito = signal<InvitoPubblico | null>(null);
|
||||||
|
|
||||||
|
readonly accepting = signal(false);
|
||||||
|
readonly acceptError = signal<string | null>(null);
|
||||||
|
|
||||||
|
get isAuthenticated(): boolean {
|
||||||
|
return this.keycloak.authenticated ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
await this.loadInvito();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInvito(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.loadError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invito = await firstValueFrom(this.invitiApi.getInvito(this.token));
|
||||||
|
this.invito.set(invito);
|
||||||
|
} catch (error) {
|
||||||
|
const httpError = error as HttpErrorResponse;
|
||||||
|
this.loadError.set(
|
||||||
|
httpError.status === 404
|
||||||
|
? 'Invito non trovato: verifica il link ricevuto.'
|
||||||
|
: "Impossibile caricare l'invito. Riprova più tardi."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async accetta(): Promise<void> {
|
||||||
|
if (!this.isAuthenticated) {
|
||||||
|
// redirectUri riporta l'utente su questa stessa pagina di invito dopo login/registrazione.
|
||||||
|
await this.keycloak.login({ redirectUri: window.location.href });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.accepting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.accepting.set(true);
|
||||||
|
this.acceptError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.invitiApi.accettaInvito(this.token));
|
||||||
|
// Il claim "organization" nel token sarà popolato solo al prossimo refresh.
|
||||||
|
await this.keycloak.updateToken(-1).catch(() => undefined);
|
||||||
|
this.accepting.set(false);
|
||||||
|
await this.router.navigateByUrl('/');
|
||||||
|
} catch (error) {
|
||||||
|
this.accepting.set(false);
|
||||||
|
this.acceptError.set(this.messageForAcceptError(error as HttpErrorResponse));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageForAcceptError(error: HttpErrorResponse): string {
|
||||||
|
switch (error.status) {
|
||||||
|
case 409:
|
||||||
|
return "L'invito è già stato accettato.";
|
||||||
|
case 410:
|
||||||
|
return "L'invito è scaduto.";
|
||||||
|
case 403:
|
||||||
|
return "Questo invito non è indirizzato al tuo account: accedi con l'email corretta.";
|
||||||
|
default:
|
||||||
|
return "Impossibile accettare l'invito. Riprova più tardi.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
keycloakBaseUrl: 'http://localhost:8081',
|
||||||
|
keycloakRealm: 'scouthub',
|
||||||
|
keycloakClientId: 'scouthub-frontend',
|
||||||
|
orgServiceApiBaseUrl: 'http://localhost:8082',
|
||||||
|
attivitaAppBaseUrl: 'http://localhost:4200'
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
keycloakBaseUrl: 'http://localhost:8081',
|
||||||
|
keycloakRealm: 'scouthub',
|
||||||
|
keycloakClientId: 'scouthub-frontend',
|
||||||
|
orgServiceApiBaseUrl: 'http://localhost:8082',
|
||||||
|
attivitaAppBaseUrl: 'http://localhost:4200'
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ScouthubHomeFe</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Include theming for Angular Material with `mat.theme()`.
|
||||||
|
// This Sass mixin will define CSS variables that are used for styling Angular Material
|
||||||
|
// components according to the Material 3 design spec.
|
||||||
|
// Learn more about theming and how to use it for your application's
|
||||||
|
// custom components at https://material.angular.dev/guide/theming
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
@include mat.theme(
|
||||||
|
(
|
||||||
|
color: (
|
||||||
|
primary: mat.$azure-palette,
|
||||||
|
tertiary: mat.$blue-palette,
|
||||||
|
),
|
||||||
|
typography: Roboto,
|
||||||
|
density: 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
// Default the application to a light color theme. This can be changed to
|
||||||
|
// `dark` to enable the dark color theme, or to `light dark` to defer to the
|
||||||
|
// user's system settings.
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
// Set a default background, font and text colors for the application using
|
||||||
|
// Angular Material's system-level CSS variables. Learn more about these
|
||||||
|
// variables at https://material.angular.dev/guide/system-variables
|
||||||
|
background-color: var(--mat-sys-surface);
|
||||||
|
color: var(--mat-sys-on-surface);
|
||||||
|
font: var(--mat-sys-body-medium);
|
||||||
|
|
||||||
|
// Reset the user agent margin.
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user