Add scouthub-home-fe
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user