Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
125a0e8
INSA: Multi tenant contribution
Nov 5, 2024
2c3c16d
INSA: Multi tenant contribution: fix logos
Nov 5, 2024
ef2ccb3
INSA: Multi tenant contribution: fix translations
Nov 5, 2024
5d05a75
INSA: Multi tenant contribution: fix Readme
Nov 5, 2024
d07f54b
INSA: Multi tenant contribution: fix notifs
Nov 5, 2024
ac5425a
INSA: Multi tenant contribution: merge develop
Nov 5, 2024
d7d2588
feat(@multi-frontend): add multi-tenant support
BorisBrogle Nov 5, 2024
20c7f20
feat(@multi-frontend): add multi-tenant support: update README
Nov 6, 2024
0a357aa
feat(@multi-frontend): add multi-tenant support: fix lint errors
Nov 6, 2024
ccb9892
feat(@multi-frontend): add multi-tenant support: update README (trans…
Nov 7, 2024
5311a44
feat(@multi-frontend): add multi-tenant support: update README (trans…
Nov 7, 2024
b26617f
feat(@multi-frontend): add multi-tenant support: update README
Mar 5, 2025
54f93dc
feat(@multi-frontend): add multi-tenant support: improvements
Mar 14, 2025
863ffff
Merge feat/INSA-multi-tenant-feature-improvements into feat/INSA-mult…
BorisBrogle Mar 17, 2025
3318c71
feat(@multi-frontend): add multi-tenant support: fix
Mar 20, 2025
6707e64
feat(@multi-frontend): add multi-tenant support: fix and improve noti…
Mar 26, 2025
83af341
feat(@multi-frontend): add multi-tenant support: fix and improve noti…
Mar 26, 2025
5c9a91f
Merge pull request #3 from BorisBrogle/feat/INSA-multi-tenant-feature…
BorisBrogle Apr 2, 2025
5980a22
Merge branch 'github/develop' into feat/INSA-multi-tenant-feature
ActxLeToucan Apr 3, 2025
0c02e29
feat(@multi-frontend): add multi-tenant support: add chevron in burge…
Apr 24, 2025
1b1ed2d
feat(@multi-frontend): add multi-tenant support: various fixes
Apr 24, 2025
86ac1a7
Merge pull request #4 from BorisBrogle/feat/INSA-multi-tenant-feature…
BorisBrogle Apr 24, 2025
8aab08c
chore: merge with develop
ActxLeToucan May 12, 2025
00ae59b
docs: maj CHANGELOG
ActxLeToucan May 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline

#### New features
* **(shared)** : Navigation, possibilité de forcer l'affichage pleine page (*full*) pour les fonctionnalités positionnées dans le menu *tabs*.
* **(multi-tenant)** : On peut désormais avoir plusieurs établissements (tenants) dans la même application, chacun pouvant définir son logo, ses traductions et son backend

#### Autres
* Migration de [NodeJS](https://nodejs.org/docs/latest-v20.x/api/index.html) : Version 18 → Version 20
Expand Down
234 changes: 234 additions & 0 deletions dev/user-frontend-ionic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,237 @@ Exemple 2 :
Alors le layout FULL est utilisé pour la route /schedule/calendar#month

Et le layout TABS est utilisé pour les routes /schedule/list, /schedule/calendar#week, /schedule/calendar#day

### Personnalisation du thème d'un tenant.

Dans les fichiers src/environments/environment.*.ts, on peut ajouter un thème par défaut dans l'attribut ```defaultTheme```
```
production: false,
languages: ['fr', 'en'],
defaultLanguage: 'fr',
firebase: firebasePwaEnvironment,
guidedTourEnabled: true,
defaultTheme: 'default',
tenants: [
...
]
```
Ce thème est utilisé par défaut si aucun thème n'est défini dans les prefs utilisateurs.

A la sélection d'un tenant, on sauvegarde l'identifiant du tenant comme étant le thème de l'application et on charge ce thème.
Au démarrage de l'application, on charge le thème sauvegardé dans les préférences utilisateurs.
S'il n'y en a pas, on charge le thème defaultTheme. Si ce dernier n'est pas défini, on ne fait rien.

Quand on désélectionne un tenant, on utilise defaultTheme ou rien - si ce dernier n'est pas défini.

Pour appliquer un thème, on ajoute un classe au body du document - à l'instar du thème sombre.

#### Modification du logo par défaut

Le fichier d'environnement, qui contient la description des tenants, a été modifié.

Un paramètre global ```defaultLogo``` a été ajouté. Il s'agit du chemin relatif (par rapport à la racine du projet) du logo à utiliser si on n'a pas de tenant sélectionné ou si le tenant n'a pas son propre logo.

Un tenant peut avoir son propre logo grace à la propriété ```logo```. Il s'agit du chemin relatif (par rapport à la racine du projet) du logo du tenant. Si la propriété n'est pas présente ou si elle est vide (ex: ''), alors le logo par défaut est utilisé.

Exemple:
```
export const environment = {
production: false,
...
defaultLogo: 'assets/logos/white-logo.svg',
tenants: [
{
id: 'other',
logo: 'assets/logos/other.svg'
...
```

## Fonctionnalité multi établissement (multi tenant)

Dans les fichiers src/environments/environment.*.ts, on peut définir plusieurs établissement à travers l'attribut `tenants` :
```
tenants: [
{
id: 'etablissement1',
name: 'Etablissement 1',
logo: 'assets/logos/logo1.svg',
apiEndpoint: 'http://localhost:3000',
cmsPublicAssetsEndpoint: 'http://localhost:8055/assets/',
topic: 'etablissement1',
modulesConfigurations: {
chatbot: {
logoRegex: /_chacha5/i
},
map: {
defaultLocation: {
longitude: 2.3488596,
latitude: 48.8533249
}
},
reservation: {
ssoServiceName: 'https://mon-espace-de-resa.fr',
ssoUrlTemplate: 'https://mon-espace-de-resa.fr/auth?ticket={st}',
}
},
},
{
id: 'etablissement2',
name: 'Etablissement 2',
logo: 'assets/logos/logo2.svg',
apiEndpoint: 'http://localhost:3000',
cmsPublicAssetsEndpoint: 'http://localhost:8055/assets/',
topic: 'etablissement2',
modulesConfigurations: {
chatbot: {
logoRegex: /_chacha5/i
},
map: {
defaultLocation: {
longitude: 2.3488596,
latitude: 48.8533249
}
},
reservation: {
ssoServiceName: 'https://mon-espace-de-resa.fr',
ssoUrlTemplate: 'https://mon-espace-de-resa.fr/auth?ticket={st}',
}
},
}
],
```

Ainsi, on peut facilement configurer un `apiEndpoint` ainsi qu'un `cmsPublicAssetsEndpoint` différent pour chacun d'entre eux afin d'utiliser un backend et/ou un cms différent selon l'établissement sélectionné.

D'autres configurations peuvent également varier en fonction de l'établissement :
- `topic` firebase utilisé
- `logo`
- `modulesConfigurations` configurations des différents modules pour lesquelles cela a du sens d'avoir une configuration propre par établissement

### Group de tenants

Il est également possible de regrouper les tenants dans un groupe de tenant, en définissant l'attribut forceSelect à false, l'application chargera la configuration du groupe sans forcer l'utilisateur à sélectionner un tenant.
Il n'est possible de créer qu'un seul groupe de tenant, celui ci devant alors se situer en première et unique position du tableau de tenant situé à la racine de la configuration:
```
tenants: [
{
id: 'etablissement',
name: 'Groupe',
isGroup: true,
forceSelect: false,
logo: 'assets/logos/logo.svg',
apiEndpoint: 'http://localhost:3000',
cmsPublicAssetsEndpoint: 'http://localhost:8055/assets/',
modulesConfigurations: {
chatbot: {
logoRegex: /_chacha5/i
},
map: {
defaultLocation: {
longitude: 2.3488596,
latitude: 48.8533249
}
},
reservation: {
ssoServiceName: 'https://mon-espace-de-resa.fr',
ssoUrlTemplate: 'https://mon-espace-de-resa.fr/auth?ticket={st}',
}
},
tenants: [
{
id: 'etablissement1',
name: 'Etablissement 1',
logo: 'assets/logos/logo1.svg',
apiEndpoint: 'http://localhost:3000',
cmsPublicAssetsEndpoint: 'http://localhost:8055/assets/',
topic: 'etablissement1',
modulesConfigurations: {
chatbot: {
logoRegex: /_chacha5/i
},
map: {
defaultLocation: {
longitude: 2.3488596,
latitude: 48.8533249
}
},
reservation: {
ssoServiceName: 'https://mon-espace-de-resa.fr',
ssoUrlTemplate: 'https://mon-espace-de-resa.fr/auth?ticket={st}',
}
},
},
{
id: 'etablissement2',
name: 'Etablissement 2',
logo: 'assets/logos/logo2.svg',
apiEndpoint: 'http://localhost:3000',
cmsPublicAssetsEndpoint: 'http://localhost:8055/assets/',
topic: 'etablissement2',
modulesConfigurations: {
chatbot: {
logoRegex: /_chacha5/i
},
map: {
defaultLocation: {
longitude: 2.3488596,
latitude: 48.8533249
}
},
reservation: {
ssoServiceName: 'https://mon-espace-de-resa.fr',
ssoUrlTemplate: 'https://mon-espace-de-resa.fr/auth?ticket={st}',
}
},
}
]
}
],
```

### Translations par tenant

Il est possible de définir des translations par tenant. Pour cela, il suffit de créer un dossier nommé suivant l'id du tenant
à l'endroit où se trouvent les translations que l'on souhaite changer, et y placer les fichiers de translations contenant ces
dernières (en.json, fr.json).

Par exemple, pour avoir des translations propres à `etablissement1` pour le module `auth`, on aura les fichiers suivants :
- `src/theme/app-theme/i18n/module/auth/en.json` : translations en par défaut
- `src/theme/app-theme/i18n/module/auth/fr.json` : translations fr par défaut
- `src/theme/app-theme/i18n/module/auth/etablissement1/en.json` : translations en propres à `etablissement1`
- `src/theme/app-theme/i18n/module/auth/etablissement1/fr.json` : translations fr propres à `etablissement1`

Les translations ajoutées de cette manière seront fusionnées avec les translations par défaut, les clés définies dans les nouveaux
fichiers écrasant les mêmes clefs des translations par défaut.

Ainsi, si on a la translation par défaut fr suivante :

```json
{
"MENU": "Menu",
"VERSION" : {
"VERSION": "Version",
"VERSION_NOT_FOUND": "Indisponible"
}
}
```
.. et que l'on veut changer la clé `VERSION_NOT_FOUND` uniquement, on va créer un fichier `etablissement1/fr.json` :

```json
{
"VERSION" : {
"VERSION_NOT_FOUND": "Version indisponible"
}
}
```

La translation finale qui sera chargée ressemblera alors à ça :
```json
{
"MENU": "Menu",
"VERSION" : {
"VERSION": "Version",
"VERSION_NOT_FOUND": "Version indisponible"
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginPage } from './login/login.page';
import { IsTenantSelectedGuard } from '@multi/shared';

const routes: Routes = [
{
path: 'auth',
component: LoginPage,
canActivate: [IsTenantSelectedGuard]
}
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@
import { Injectable } from '@angular/core';
import { Actions } from '@ngneat/effects-ng';
import {
authenticate, AuthenticatedUser,
cleanupPrivateData, getAuthToken, getRefreshAuthToken, updateAuthenticatedUsername
authenticate, AuthenticatedUser, userIsAuthenticated$,
cleanupPrivateData, getAuthToken, getRefreshAuthToken, updateAuthenticatedUsername, MultiTenantService
} from '@multi/shared';
import { Observable } from 'rxjs';
import { concatMap, take, tap } from 'rxjs/operators';
import { Observable, withLatestFrom } from 'rxjs';
import { concatMap, filter, switchMap, take, tap } from 'rxjs/operators';
import { saveCredentialsOnAuthentication$ } from '../preferences/preferences.repository';
import { KeepAuthService } from './keep-auth.service';
import { StandardAuthService } from './standard-auth.service';
Expand All @@ -58,7 +58,15 @@ export class AuthService {
private actions: Actions,
private standardAuthService: StandardAuthService,
private keepAuthService: KeepAuthService,
) { }
private multiTenantService: MultiTenantService,
) {
// Si l'utilisateur passe en mode anonyme et qu'il est toujours connecté, on le déconnecte
this.multiTenantService.tenantChange$.pipe(
withLatestFrom(userIsAuthenticated$),
filter(([tenant, isAuthenticated]) => tenant === undefined && isAuthenticated),
switchMap(() => this.logout())
).subscribe();
}

login(username: string, password: string): Observable<AuthenticatedUser | null> {
return saveCredentialsOnAuthentication$.pipe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
*/

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { AuthenticatedUser, getAuthToken, updateAuthToken, updateRefreshAuthToken, updateUser } from '@multi/shared';
import { Injectable } from '@angular/core';
import { AuthenticatedUser, getAuthToken, MultiTenantService, updateAuthToken, updateRefreshAuthToken, updateUser } from '@multi/shared';
import { EMPTY, Observable, of, throwError, zip } from 'rxjs';
import { catchError, concatMap, delayWhen, take } from 'rxjs/operators';

Expand All @@ -55,13 +55,12 @@ export class KeepAuthService {


constructor(
@Inject('environment')
private environment: any,
private multiTenantService: MultiTenantService,
private http: HttpClient) { }

login(username: string, password: string): Observable<AuthenticatedUser | null> {

const url = `${this.environment.apiEndpoint}/keep-auth/auth`;
const url = `${this.multiTenantService.getApiEndpoint()}/keep-auth/auth`;
const data = {
username,
password,
Expand Down Expand Up @@ -96,7 +95,7 @@ export class KeepAuthService {
}

logout(refreshAuthToken: string): Observable<boolean> {
const url = `${this.environment.apiEndpoint}/keep-auth/auth`;
const url = `${this.multiTenantService.getApiEndpoint()}/keep-auth/auth`;
const headers = {
// eslint-disable-next-line @typescript-eslint/naming-convention
Authorization: `Bearer ${refreshAuthToken}`
Expand All @@ -120,7 +119,7 @@ export class KeepAuthService {
}

removeSavedCredentials(refreshAuthToken: string): Observable<boolean> {
const url = `${this.environment.apiEndpoint}/keep-auth/reauth`;
const url = `${this.multiTenantService.getApiEndpoint()}/keep-auth/reauth`;
const headers = {
// eslint-disable-next-line @typescript-eslint/naming-convention
Authorization: `Bearer ${refreshAuthToken}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
*/

import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { MultiTenantService } from '@multi/shared';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { LoginPageContent, LoginRepository } from './login.repository';
Expand All @@ -48,13 +49,12 @@ import { LoginPageContent, LoginRepository } from './login.repository';
})
export class LoginService {
constructor(
@Inject('environment')
private environment: any,
private multiTenantService: MultiTenantService,
private http: HttpClient,
private loginRepository: LoginRepository) { }

public loadAndStoreLoginPageContent(): Observable<LoginPageContent> {
const url = `${this.environment.apiEndpoint}/auth/login-page-content`;
const url = `${this.multiTenantService.getApiEndpoint()}/auth/login-page-content`;

return this.http.get<LoginPageContent>(url).pipe(
tap((pageContent) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
*/

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { AuthenticatedUser, getAuthToken, updateAuthToken, updateUser } from '@multi/shared';
import { Injectable } from '@angular/core';
import { AuthenticatedUser, getAuthToken, MultiTenantService, updateAuthToken, updateUser } from '@multi/shared';
import { Observable, of, throwError } from 'rxjs';
import { catchError, concatMap, delayWhen, take } from 'rxjs/operators';

Expand All @@ -54,13 +54,12 @@ export class StandardAuthService {


constructor(
@Inject('environment')
private environment: any,
private multiTenantService: MultiTenantService,
private http: HttpClient) { }

login(username: string, password: string): Observable<AuthenticatedUser | null> {

const url = `${this.environment.apiEndpoint}/auth`;
const url = `${this.multiTenantService.getApiEndpoint()}/auth`;
const data = {
username,
password,
Expand All @@ -85,7 +84,7 @@ export class StandardAuthService {
}

logout(): Observable<boolean> {
const url = `${this.environment.apiEndpoint}/auth`;
const url = `${this.multiTenantService.getApiEndpoint()}/auth`;

return getAuthToken().pipe(
take(1),
Expand Down
Loading