Add scouthub-home-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:40:51 +02:00
parent 566754f825
commit c34bca869f
49 changed files with 10370 additions and 0 deletions
+17
View File
@@ -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
+45
View File
@@ -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/
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+16
View File
@@ -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
+155
View File
@@ -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
```
+80
View File
@@ -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"
}
}
}
}
}
+10
View File
@@ -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;
}
}
+8767
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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>
+33
View File
@@ -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]))
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+20
View File
@@ -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)
}
];
+16
View File
@@ -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();
});
});
+10
View File
@@ -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 });
}
}
+39
View File
@@ -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;
}
+13
View File
@@ -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 + '/' });
});
});
+25
View File
@@ -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();
});
});
+96
View File
@@ -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'
};
+20
View File
@@ -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>
+6
View File
@@ -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));
+38
View File
@@ -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%;
}
+1
View File
@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */
+14
View File
@@ -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"
]
}
+31
View File
@@ -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"
}
]
}
+15
View File
@@ -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"
]
}