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