diff --git a/.babelrc b/.babelrc index dacfb573..b622e6f8 100644 --- a/.babelrc +++ b/.babelrc @@ -8,5 +8,11 @@ } ] ], + "env": { + "dev": { + "compact": false, + "minified": false + } + }, "compact": true } diff --git a/.env b/.env index b938ec88..09bcd978 100644 --- a/.env +++ b/.env @@ -40,6 +40,7 @@ VITE_ARROW_MODEL_URL="/static-ngeo/models/arrow5.glb" VITE_ELEVATION_URL="/raster" # Auth +VITE_CREDENTIALS_ORIGIN="same-origin" VITE_LOGIN_URL="/login" VITE_LOGOUT_URL="/logout" VITE_USERINFO_URL="/getuserinfo" diff --git a/.env.development b/.env.development index 92779622..987acab9 100644 --- a/.env.development +++ b/.env.development @@ -40,9 +40,10 @@ VITE_ARROW_MODEL_URL="https://migration.geoportail.lu/static-ngeo/models/arrow5. VITE_ELEVATION_URL="https://migration.geoportail.lu/raster" # Auth -VITE_LOGIN_URL="https://migration.geoportail.lu/login" -VITE_LOGOUT_URL="https://migration.geoportail.lu/logout" -VITE_USERINFO_URL="https://migration.geoportail.lu/getuserinfo" +VITE_CREDENTIALS_ORIGIN="include" +VITE_LOGIN_URL="http://localhost:8080/login" +VITE_LOGOUT_URL="http://localhost:8080/logout" +VITE_USERINFO_URL="http://localhost:8080/getuserinfo" VITE_MYACCOUNT_URL="https://myaccount.geoportail.lu" VITE_MYACCOUNT_RECOVER_URL="https://myaccount.geoportail.lu/recover-password" VITE_MYACCOUNT_NEW_URL="https://myaccount.geoportail.lu/new-user" diff --git a/.env.e2e b/.env.e2e index 7418e2ff..1a25ee8f 100644 --- a/.env.e2e +++ b/.env.e2e @@ -40,6 +40,7 @@ VITE_ARROW_MODEL_URL="https://migration.geoportail.lu/static-ngeo/models/arrow5. VITE_ELEVATION_URL="https://migration.geoportail.lu/raster" # Auth +VITE_CREDENTIALS_ORIGIN="same-origin" VITE_LOGIN_URL="https://migration.geoportail.lu/login" VITE_LOGOUT_URL="https://migration.geoportail.lu/logout" VITE_USERINFO_URL="https://migration.geoportail.lu/getuserinfo" diff --git a/.env.staging b/.env.staging index 2fc0526d..d11cc700 100644 --- a/.env.staging +++ b/.env.staging @@ -40,9 +40,10 @@ VITE_ARROW_MODEL_URL="https://migration.geoportail.lu/static-ngeo/models/arrow5. VITE_ELEVATION_URL="https://migration.geoportail.lu/raster" # Auth -VITE_LOGIN_URL="https://migration.geoportail.lu/login" -VITE_LOGOUT_URL="https://migration.geoportail.lu/logout" -VITE_USERINFO_URL="https://migration.geoportail.lu/getuserinfo" +VITE_CREDENTIALS_ORIGIN="same-origin" +VITE_LOGIN_URL="/login" +VITE_LOGOUT_URL="/logout" +VITE_USERINFO_URL="/getuserinfo" VITE_MYACCOUNT_URL="https://myaccount.geoportail.lu" VITE_MYACCOUNT_RECOVER_URL="https://myaccount.geoportail.lu/recover-password" VITE_MYACCOUNT_NEW_URL="https://myaccount.geoportail.lu/new-user" diff --git a/README.md b/README.md index 9045d200..569a1b4b 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ You can include the built lib multiple ways in the `package.json`: } ``` -### Develop on lib within geoportailv3 environnement +### Develop on lib within geoportailv3 environment A simple way to develop on the lib and test it directly from within the geoportailv3 context is to map your `luxembourg-geoportal` repository as a volume to webpack_dev_server service of the docker composition: @@ -267,3 +267,54 @@ To create custom components in the application using the lib, adapt the followin const LayerPanelElement = createElementInstance(LayerPanel, app) customElements.define('layer-panel', LayerPanelElement) ``` + +## 🔒 Authenticate user + +To authenticate inside the v4 standalone app, you will need the v3 composition to be running at the same time and make some adjustement on both sides: + +- in v4, update `VITE_LOGIN_URL`, `VITE_LOGOUT_URL` and `VITE_USERINFO_URL` to point to your local v3 composition + +```bash +# file: luxembourg-geoportail/.env.development +VITE_LOGIN_URL="http://localhost:8080/login" +VITE_LOGOUT_URL="http://localhost:8080/logout" +VITE_USERINFO_URL="http://localhost:8080/getuserinfo" +``` + +- in v4, activate cross origin for GET/POST requests with a custom `VITE_CREDENTIALS_ORIGIN`. + +```bash +# file: luxembourg-geoportail/.env.development +VITE_CREDENTIALS_ORIGIN="include" +# ⚠️ WARNING: don't use `"include"` value in production (but use `"same-origin"` instead). +``` + +- in v3, add a new env variable in `docker-compose.yaml`: `ALLOW_CORS` + +```yaml +# file: geoportailv3/docker-compose.yaml +geoportal: + extends: ... + volumes_from: ... + volumes: ... + environment: ... + - VECTORTILESURL + - ALLOW_CORS # <=== Add new var here! + ports: + - 8080:8080 +``` + +- and set it to value = `1` in the `.env.project` file to allow cors and cross origin requests. + +```bash +# file: geoportailv3/.env.project +ALLOW_CORS=1 # ⚠️WARNING: don't use this value in production +``` + +## 🛡️ By pass CORS in dev mode + +Because v4 is a standalone app with no backend, it uses sometimes local v3's backend and sometimes migration platform https://migration.geoportail.lu to perform api calls (see dedicated `.env` files to check urls). + +To ignore CORS errors when performing these calls, it is mandatory to use a plugin in your web browser (such as Use Allow CORS plugin for Chrome: https://mybrowseraddon.com/access-control-allow-origin.html). Without the plugin functionnalities such as MyMaps, authentication, MySymbols, ... won't work. + +💡 NB. For e2e testing, CORS securities have been deactivated with the Cypress option: `chromeWebSecurity: false` (only avaialable for Chrome browser). diff --git a/package.json b/package.json index 06fe496b..cb2fe066 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "run-p type-check build-only", "build-only": "vite build", "build:lib:prod": "npx vite build --mode prod --config vite-dist.config.ts --minify=esbuild --debug && npx babel bundle/lux.dist.mjs --out-file bundle/lux.dist.js", - "build:lib:dev": "npx vite build --mode staging --config vite-dist.config.ts --minify=false --base=/dev/main.html/ --debug && cp bundle/lux.dist.mjs bundle/lux.dist.js", + "build:lib:dev": "npx vite build --mode staging --config vite-dist.config.ts --minify=false --base=/dev/main.html/ --debug && BABEL_ENV=dev npx babel bundle/lux.dist.mjs --out-file bundle/lux.dist.js", "preview": "vite preview", "test": "npm run test:unit", "test:unit": "vitest --environment jsdom --root .", diff --git a/src/assets/main.css b/src/assets/main.css index 3f7de88d..9f0a0085 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -404,7 +404,7 @@ } .lux-account-tab { - @apply ml-1 bg-primary text-white after:content-['\E02E'] after:font-icons after:text-3xl after:ml-4 w-20 px-2 pt-1; + @apply ml-1 bg-primary text-white after:content-['\E02E'] after:font-icons after:text-3xl after:ml-4 w-20 px-2 pt-1 mb-0 border-none; } .lux-account-content { diff --git a/src/bundle/lib.ts b/src/bundle/lib.ts index 7db6daf0..18533bab 100644 --- a/src/bundle/lib.ts +++ b/src/bundle/lib.ts @@ -11,6 +11,7 @@ import './lib.css' // Tell Vite to build the css import '../assets/main.css' // Tell Vite to build the css import AlertNotifications from '@/components/alert-notifications/alert-notifications.vue' +import AuthForm from '@/components/auth/auth-form.vue' import DropdownList from '@/components/common/dropdown-list.vue' import MapContainer from '@/components/map/map-container.vue' import BackgroundSelector from '@/components/background-selector/background-selector.vue' @@ -33,6 +34,7 @@ import { useAppStore } from '@/stores/app.store' import { useMapStore } from '@/stores/map.store' import { useStyleStore } from '@/stores/style.store' import { useThemeStore } from '@/stores/config.store' +import { useUserManagerStore } from '@/stores/user-manager.store' import { statePersistorBgLayerService } from '@/services/state-persistor/state-persistor-layer-background.service' import { statePersistorLayersService } from '@/services/state-persistor/state-persistor-layers.service' import { statePersistorThemeService } from '@/services/state-persistor/state-persistor-theme.service' @@ -118,6 +120,7 @@ export { VueDOMPurifyHTML, I18NextVue, AlertNotifications, + AuthForm, DropdownList, MapContainer, BackgroundSelector, @@ -142,6 +145,7 @@ export { useMapStore, useStyleStore, useThemeStore, + useUserManagerStore, statePersistorBgLayerService, statePersistorLayersService, statePersistorThemeService, diff --git a/src/components/auth/auth-form.spec.ts b/src/components/auth/auth-form.spec.ts index 98fbca06..bfc9cb95 100644 --- a/src/components/auth/auth-form.spec.ts +++ b/src/components/auth/auth-form.spec.ts @@ -2,7 +2,7 @@ import { shallowMount, VueWrapper } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' import { useUserManagerStore } from '@/stores/user-manager.store' -import * as AuthService from '@/services/auth/auth.service' +import { authService } from '@/services/auth/auth.service' import AuthForm from './auth-form.vue' describe('AuthForm', () => { @@ -44,7 +44,7 @@ describe('AuthForm', () => { }) it('should call AuthService.authenticate on submit', async () => { - const authenticateMock = vi.spyOn(AuthService, 'authenticate') + const authenticateMock = vi.spyOn(authService, 'authenticate') const userNameInput = wrapper.find('input[name="userName"]') const userPasswordInput = wrapper.find('input[name="userPassword"]') @@ -83,7 +83,7 @@ describe('AuthForm', () => { }) it('should call AuthService.logout on logout', async () => { - const logoutMock = vi.spyOn(AuthService, 'logout') + const logoutMock = vi.spyOn(authService, 'logout') await wrapper.find('button').trigger('click') diff --git a/src/components/auth/auth-form.vue b/src/components/auth/auth-form.vue index 7e113ae1..ba08fc35 100644 --- a/src/components/auth/auth-form.vue +++ b/src/components/auth/auth-form.vue @@ -3,7 +3,7 @@ import { onMounted, ref, watch } from 'vue' import { useTranslation } from 'i18next-vue' import { storeToRefs } from 'pinia' -import * as AuthService from '@/services/auth/auth.service' +import { authService } from '@/services/auth/auth.service' import { useAlertNotificationsStore } from '@/stores/alert-notifications.store' import { AlertNotificationType } from '@/stores/alert-notifications.store.model' import { useAppStore } from '@/stores/app.store' @@ -20,25 +20,31 @@ const { lang, isApp } = storeToRefs(useAppStore()) const userManagerStore = useUserManagerStore() const { setCurrentUser, clearUser } = userManagerStore const { authenticated, currentUser } = storeToRefs(userManagerStore) +const autoAuthenticated = ref(false) // Will be set to true if user is authenticated via cookie on first call AuthService.getUserInfo() const userName = ref('') const userPassword = ref('') watch(authenticated, authenticated => { - if (authenticated) { + if (!autoAuthenticated.value && authenticated) { addNotification(t('Vous êtes maintenant correctement connecté.')) } }) onMounted(() => { - AuthService.getUserInfo() - .then(onAuthenticateSuccess) + authService + .getUserInfo() + .then(user => { + autoAuthenticated.value = true + onAuthenticateSuccess(user) + }) .catch(() => { // do nothing, don't display errors }) }) function logout() { - AuthService.logout() + authService + .logout() .then(() => clearUser()) .catch(() => addNotification( @@ -50,8 +56,12 @@ function logout() { } function submit() { - AuthService.authenticate(userName.value, userPassword.value, isApp.value) - .then(onAuthenticateSuccess) + authService + .authenticate(userName.value, userPassword.value, isApp.value) + .then(user => { + autoAuthenticated.value = false + onAuthenticateSuccess(user) + }) .catch(onAuthenticateFailure) resetAuthForm() } diff --git a/src/services/auth/auth.service.spec.ts b/src/services/auth/auth.service.spec.ts index cbb1ac63..637f8513 100644 --- a/src/services/auth/auth.service.spec.ts +++ b/src/services/auth/auth.service.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, MockedFunction, vi } from 'vitest' import { User, UserApi } from '@/stores/user-manager.store.model' -import { authenticate, logout, getUserInfo } from './auth.service' +import { authService } from './auth.service' global.fetch = vi.fn() @@ -51,16 +51,16 @@ describe('Auth service', () => { it('should authenticate user successfully and return user info', async () => { mockFetchUserApiSuccess() - const result = await authenticate('the_user', 'the_password') + const result = await authService.authenticate('the_user', 'the_password') expect(result).toStrictEqual(resultUserInfo) }) it('should throw error when authentication fails', async () => { mockFetchError() - await expect(authenticate('the_user', 'the_password')).rejects.toThrow( - 'Error while trying to authenticate user' - ) + await expect( + authService.authenticate('the_user', 'the_password') + ).rejects.toThrow('Error while trying to authenticate user') }) }) @@ -71,14 +71,14 @@ describe('Auth service', () => { text: vi.fn().mockResolvedValue('success'), }) - const result = await logout() + const result = await authService.logout() expect(result).toBe('success') }) it('should throw error when logout fails', async () => { mockFetchError() - await expect(logout()).rejects.toThrow( + await expect(authService.logout()).rejects.toThrow( 'Error while trying to logout user' ) }) @@ -88,14 +88,14 @@ describe('Auth service', () => { it('should get the user info', async () => { mockFetchUserApiSuccess() - const result = await getUserInfo() + const result = await authService.getUserInfo() expect(result).toStrictEqual(resultUserInfo) }) it('should throw error when getUserInfo fails', async () => { mockFetchError() - await expect(getUserInfo()).rejects.toThrow( + await expect(authService.getUserInfo()).rejects.toThrow( 'Error while trying to get user info' ) }) diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts index dfeaf5e3..1e8e18d6 100644 --- a/src/services/auth/auth.service.ts +++ b/src/services/auth/auth.service.ts @@ -1,106 +1,111 @@ import { User, UserApi } from '@/stores/user-manager.store.model' +const CREDENTIALS_ORIGIN = import.meta.env.VITE_CREDENTIALS_ORIGIN const LOGIN_URL = import.meta.env.VITE_LOGIN_URL const LOGOUT_URL = import.meta.env.VITE_LOGOUT_URL const USERINFO_URL = import.meta.env.VITE_USERINFO_URL -/** - * Calls "/login" url to authenticate user - * @param userName The user's name, mandatory - * @param userPassword The user's password, mandatory - * @param isApp If the app is mobile app mode, false by default - * @returns The api returns user's info is succeeded - */ -export async function authenticate( - userName: string, - userPassword: string, - isApp = false -) { - const payload = new URLSearchParams({ - login: userName, - password: userPassword, - app: isApp ? 'true' : 'false', - }) - const response = await fetch(LOGIN_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: payload, - }) +export class AuthService { + /** + * Calls "/login" url to authenticate user + * @param userName The user's name, mandatory + * @param userPassword The user's password, mandatory + * @param isApp If the app is mobile app mode, false by default + * @returns The api returns user's info is succeeded + */ + async authenticate(userName: string, userPassword: string, isApp = false) { + const payload = new URLSearchParams({ + login: userName, + password: userPassword, + app: isApp ? 'true' : 'false', + }) + const response = await fetch(LOGIN_URL, { + method: 'POST', + credentials: CREDENTIALS_ORIGIN, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: payload, + }) - if (!response.ok) { - throw new Error('Error while trying to authenticate user') + if (!response.ok) { + throw new Error('Error while trying to authenticate user') + } + + const data = await response.json() + + return this.mapUserApiToUser(data) } - const data = await response.json() + /** + * Calls "/logout" url to log out the user + * @returns The api returns true if succeeded + */ + async logout() { + const response = await fetch(LOGOUT_URL, { + credentials: CREDENTIALS_ORIGIN, + }) - return mapUserApiToUser(data) -} + if (!response.ok) { + throw new Error('Error while trying to logout user') + } -/** - * Calls "/logout" url to log out the user - * @returns The api returns true if succeeded - */ -export async function logout() { - const response = await fetch(LOGOUT_URL) + const data = await response.text() - if (!response.ok) { - throw new Error('Error while trying to logout user') + return data } - const data = await response.text() - - return data -} + /** + * Calls "/getuserinfo" url to get user's info, the user needs to be authenticated first + * @returns The api returns user's info is succeeded + */ + async getUserInfo() { + const payload = new URLSearchParams({}) + const response = await fetch(USERINFO_URL, { + method: 'POST', + credentials: CREDENTIALS_ORIGIN, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: payload, + }) -/** - * Calls "/getuserinfo" url to get user's info, the user needs to be authenticated first - * @returns The api returns user's info is succeeded - */ -export async function getUserInfo() { - const payload = new URLSearchParams({}) - const response = await fetch(USERINFO_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: payload, - }) + const data = response.ok && (await response.json()) - const data = response.ok && (await response.json()) + if (!data.login) { + throw new Error('Error while trying to get user info') + } - if (!data.login) { - throw new Error('Error while trying to get user info') + return this.mapUserApiToUser(data) } - return mapUserApiToUser(data) -} - -/** - * Transform User info returned by api to User model - * @param userApi The user info returned by the api - */ -function mapUserApiToUser(userApi: UserApi) { - const { - is_admin, - login, - mail, - mymaps_role, - role, - role_id, - sn, - typeUtilisateur, - } = userApi - return { - login, - mail, - isAdmin: is_admin, - mymapsRole: mymaps_role, - name: sn, - role, - roleId: role_id, - sn, - typeUtilisateur, + /** + * Transform User info returned by api to User model + * @param userApi The user info returned by the api + */ + private mapUserApiToUser(userApi: UserApi) { + const { + is_admin, + login, + mail, + mymaps_role, + role, + role_id, + sn, + typeUtilisateur, + } = userApi + return { + login, + mail, + isAdmin: is_admin, + mymapsRole: mymaps_role, + name: sn, + role, + roleId: role_id, + sn, + typeUtilisateur, + } } } + +export const authService = new AuthService() diff --git a/vite-dist.config.ts b/vite-dist.config.ts index 8e283661..d1377893 100644 --- a/vite-dist.config.ts +++ b/vite-dist.config.ts @@ -41,6 +41,7 @@ export default defineConfig(({ mode }) => { entry: resolve(__dirname, 'src/bundle/lib.ts'), name: 'luxembourg-geoportail', fileName: 'lux.dist', + formats: ['es'], // outputs only bundle/lux.dist.mjs }, commonjsOptions: { exclude: ['ol', 'mapbox-gl'],