diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f67fea8..a704a84 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,6 +12,7 @@ module.exports = { rules: { 'vue/multi-word-component-names': 'off', 'no-undef': 'off', + 'vue/require-v-for-key': 'warn', }, parserOptions: { ecmaVersion: 'latest', diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b7b60da..a7cea0b 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,4 +1,3 @@ { - "recommendations": [ - "Vue.volar""] + "recommendations": ["Vue.volar"] } diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..a11b800 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,69 @@ +# Configuration file + +A configuration file can be set to customize the header. + +Inside it, you can set the following properties: +```json +{ + "config": {}, + "menu": [], + "i18n": {} +} +``` + +## Config + +Config can contains old tag attributes : +```json + "config": { + "stylesheet": "https://data.lillemetropole.fr/public/georchestra.css", + "logoUrl": "https://data.lillemetropole.fr/public/logo-mel.jpg", + "hideLogin": false, + "lang": "es" + } +``` + +Full configuration [is here](./src/config-interfaces.ts#L32-L53). + +:warning: You can also define stylesheet in the datadir (`default.properties`) because this file can be used in other georchestra's apps. It will take precedence over the one set in the config file of the header. + +## Menu + +Menu can contain three type of objects : `link` (by default), `separator` or `dropdown` + +There's actually just one level of dropdowns. You cannot have a dropdown inside a dropdown. + +To see the actual structure of the menu, you can check the [menu interface](./src/default-config.json) + +### Active tab matching + +A decision has been made in order to have the best match between the active tab and the current page. + +If two conditions can be resolved for a link to be active, the longest one will be used. + +URL of tab : /mapstore/#/home + +- Condition 1 : activeAppUrl /mapstore +- Condition 2 : activeAppUrl /mapstore/#/home + +Condition 2 will be used because /mapstore/#/home = 16 characters. + + +## i18n + +In addition to translations set in [./src/i18n/](./src/i18n/), you can add custom translations : +```json +{ + "i18n": { + "en": { + "customi18nkey": "WMS/WFS service" + }, + "fr": { + "customi18nkey": "Service WMS/WFS" + }, + "es": { + "customi18nkey": "Servicio WMS/WFS" + } + } +} +``` \ No newline at end of file diff --git a/README.md b/README.md index 5404104..6fe515a 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,15 @@ Iframe can still be set with defining `legacy-url` attribute, style can also be Attributes available : -| Attribute | Description | Example | For host | For legacy | -| ------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------- | ---------- | -| hideLogin | Used to hide the login buttton | `` | v | | -| lang | Used to force header language (default value : en) | `` | v | | -| active-app | Use this attribute to set the active class in menu | `` | v | v | -| logo-url | Use this attribute to set the logo for the new header (not legacy one). | `` | v | | +| Attribute | Description | Example | For new header | For legacy header (iframe) | +|-------------|-------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------| -------------- |----------------------------| +| active-app | Use this attribute to set the active class in menu | `` | | v | +| config-file | Use this attribute to set the config file for the new header (not legacy one). See [CONFIG.md](./CONFIG.md) | `` | v | | +| stylesheet | adds this stylesheet to the new header (not legacy one). | `` | v | | +| height | sets the height of the header (in pixels) | `` | v | v | | legacy-header | Use this attribute to enable the legacy header `iframe` tag. Needs `legacy-url`. | `` | | v | | legacy-url | Legacy URL: if set, activates iframe with src attribute pointing to this URL. Needs `legacy-header`. | `` | | v | -| style | adds this style to iframe or host tag (if legacy url is not used) | `` | v | v | -| stylesheet | adds this stylesheet to host tag | `` | v | | +| logo-url | Use this attribute to set the logo for the new header (not legacy one). | `` | v | | 3. Provide a custom stylesheet diff --git a/index.html b/index.html index 7bb500c..e6a54f2 100644 --- a/index.html +++ b/index.html @@ -13,10 +13,7 @@ geOrchestra header - + diff --git a/public/sample-config.json b/public/sample-config.json new file mode 100644 index 0000000..a29f8bb --- /dev/null +++ b/public/sample-config.json @@ -0,0 +1,59 @@ +{ + "config": { + "stylesheet": "/public/georchestra.css", + "logoUrl": "/public/logo-mel.jpg", + "hideLogin": false, + "iconsUrl": "https://cdn.jsdelivr.net/gh/iconoir-icons/iconoir@main/css/iconoir.css", + "lang": "es" + }, + "menu": [ + { + "label": "Catalogue", + "i18n": "datahub", + "url": "/datahub/", + "activeAppUrl": "/datahub" + }, + { + "label": "WMS/WFS", + "i18n": "customi18n", + "url": "/geoserver/web", + "activeAppUrl": "includes:/geoserver", + "icon": "iconoir-map" + }, + { + "type": "separator" + }, + { + "type": "dropdown", + "label": "A dropdown", + "items": [ + { + "label": "Console", + "i18n": "users", + "url": "/console/manager/home", + "activeAppUrl": "/console", + "icon": "iconoir-globe" + }, + { + "label": "Geonetwork", + "i18n": "catalogue", + "url": "/geonetwork/srv/:lang3/catalog.edit#/board", + "activeAppUrl": "/geonetwork", + "hasRole": "ROLE_GN_EDITOR", + "blockedRole": "ROLE_SUPERUSER,ROLE_GN_REVIEWER,ROLE_GN_ADMIN" + } + ] + } + ], + "i18n": { + "en": { + "customi18n": "WMS/WFS service" + }, + "fr": { + "customi18n": "Service WMS/WFS" + }, + "es": { + "customi18n": "Servicio WMS/WFS" + } + } +} diff --git a/src/auth.ts b/src/auth.ts index ab6f63b..0a395db 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,23 +1,9 @@ const AUTH_API_URL = '/whoami' const CONSOLE_PLATFORM_API_URL = '/console/private/platform/infos' -type KNOWN_ROLES = - | 'ROLE_SUPERUSER' - | 'ROLE_ORGADMIN' - | 'ROLE_MAPSTORE_ADMIN' - | 'ROLE_USER' - | 'ROLE_ADMINISTRATOR' - | 'ROLE_EXTRACTORAPP' - | 'ROLE_GN_REVIEWER' - | 'ROLE_GN_EDITOR' - | 'ROLE_GN_ADMIN' - | 'ROLE_EMAILPROXY' - | 'ROLE_ANONYMOUS' - | 'ROLE_IMPORT' - interface WhoAmIResponse { GeorchestraUser: { - roles: KNOWN_ROLES[] + roles: string[] username: string firstName: string lastName: string @@ -33,17 +19,7 @@ export interface User { anonymous: boolean warned: boolean remainingDays: string - adminRoles: AdminRoles | null -} - -export interface AdminRoles { - superUser: boolean - admin: boolean - console: boolean - catalog: boolean - catalogAdmin: boolean - viewer: boolean - import: boolean + roles: string[] } export async function getUserDetails(): Promise { @@ -57,42 +33,21 @@ export async function getUserDetails(): Promise { warned: false, remainingDays: '0', anonymous: true, - adminRoles: null, + roles: ['ROLE_ANONYMOUS'], } } - const roles = user.roles return { username: user.username, firstname: user.firstName, lastname: user.lastName, warned: user.ldapWarn, remainingDays: user.ldapRemainingDays, - anonymous: roles.indexOf('ROLE_ANONYMOUS') > -1, - adminRoles: getAdminRoles(roles), + anonymous: user.roles.indexOf('ROLE_ANONYMOUS') > -1, + roles: user.roles, } }) } -export function getAdminRoles(roles: KNOWN_ROLES[]): AdminRoles | null { - const superUser = roles.indexOf('ROLE_SUPERUSER') > -1 - const console = superUser || roles.indexOf('ROLE_ORGADMIN') > -1 - const catalogAdmin = superUser || roles.indexOf('ROLE_GN_ADMIN') > -1 - const catalog = !catalogAdmin && (roles.indexOf('ROLE_GN_EDITOR') > -1 || roles.indexOf('ROLE_GN_REVIEWER') > -1) - const viewer = superUser || roles.indexOf('ROLE_MAPSTORE_ADMIN') > -1 - const admin = - superUser || console || catalog || viewer || catalogAdmin - if (!admin && roles.indexOf('ROLE_IMPORT') === -1) return null - return { - superUser, - admin, - console, - catalog, - catalogAdmin, - viewer, - import: superUser || roles.indexOf('ROLE_IMPORT') > -1, - } -} - export interface PlatformInfos { analyticsEnabled: boolean extractorappEnabled: boolean diff --git a/src/config-interfaces.ts b/src/config-interfaces.ts new file mode 100644 index 0000000..2791dfb --- /dev/null +++ b/src/config-interfaces.ts @@ -0,0 +1,47 @@ +interface MenuItem { + type?: string + //Role required to display the item + hasRole?: string + //Role which hides the item + blockedRole?: string +} + +export interface Link extends MenuItem { + label: string + //URL to redirect to + url: string + //i18n key to display the label + i18n?: string + //to trigger the active tab (underline). By default, it triggers on start with e.g /console (start:/console) will trigger on /console** pages + // Values can be 'start', 'exact', 'includes', 'end' + activeAppUrl?: string + //Icon to display next to the label + icon?: string +} + +export interface Dropdown extends MenuItem { + label: string + //i18n key to display the label + i18n?: string + //List of items to display in the dropdown + items?: Array +} + +export interface Separator extends MenuItem {} + +export interface Config { + //Logo url to display in the header + logoUrl?: string + //Title to the logo displayed in the header + logoTitle?: string + //Whether to hide the login button + hideLogin?: boolean + //Custom stylesheet to apply to the header + stylesheet?: string + //Link to icons url. Tested with https://cdn.jsdelivr.net/gh/iconoir-icons/iconoir@main/css/iconoir.css + iconsUrl?: string + //Force header's language + lang?: string + //List of roles considered as admin roles, if admin, triggers a request to /console/private/platform/infos + adminRoles: string[] +} diff --git a/src/default-config.json b/src/default-config.json new file mode 100644 index 0000000..acdf264 --- /dev/null +++ b/src/default-config.json @@ -0,0 +1,87 @@ +{ + "defaultConfig": { + "hideLogin": false, + "adminRoles": [ + "ROLE_SUPERUSER", + "ROLE_ORGADMIN", + "ROLE_GN_ADMIN", + "ROLE_GN_REVIEWER", + "ROLE_GN_EDITOR", + "ROLE_MAPSTORE_ADMIN" + ] + }, + "defaultMenu": [ + { + "label": "Catalogue", + "i18n": "catalogue", + "url": "/datahub/", + "activeAppUrl": "/datahub" + }, + { + "label": "Mapstore viewer", + "i18n": "viewer", + "url": "/mapstore", + "activeAppUrl": "/mapstore" + }, + { + "label": "Maps", + "i18n": "maps", + "url": "/mapstore/#/home", + "activeAppUrl": "/mapstore/#/home" + }, + { + "label": "Services", + "i18n": "services", + "url": "/geoserver/web", + "activeAppUrl": "/geoserver" + }, + { + "label": "Datafeeder", + "i18n": "datafeeder", + "url": "/import", + "activeAppUrl": "/import", + "hasRole": "ROLE_SUPERUSER,ROLE_IMPORT" + }, + { + "hasRole": "ROLE_SUPERUSER,ROLE_GN_EDITOR,ROLE_GN_ADMIN,ROLE_MAPSTORE_ADMIN", + "type": "separator" + }, + { + "type": "dropdown", + "label": "Administration", + "i18n": "admin", + "hasRole": "ROLE_SUPERUSER,ROLE_GN_EDITOR,ROLE_GN_ADMIN,ROLE_MAPSTORE_ADMIN", + "items": [ + { + "label": "Geonetwork", + "i18n": "catalogue", + "url": "/geonetwork/srv/:lang3/catalog.edit#/board", + "activeAppUrl": "/geonetwork", + "hasRole": "ROLE_GN_EDITOR", + "blockedRole": "ROLE_SUPERUSER,ROLE_GN_REVIEWER,ROLE_GN_ADMIN" + }, + { + "label": "Geonetwork", + "i18n": "catalogue", + "url": "/geonetwork/srv/:lang3/admin.console", + "activeAppUrl": "/geonetwork", + "hasRole": "ROLE_SUPERUSER,ROLE_GN_REVIEWER,ROLE_GN_ADMIN" + }, + { + "label": "Viewer", + "i18n": "viewer", + "url": "/mapstore/#/admin", + "activeAppUrl": "/mapstore/#/admin", + "hasRole": "ROLE_SUPERUSER,ROLE_MAPSTORE_ADMIN" + }, + { + "label": "Console", + "i18n": "users", + "url": "/console/manager/home", + "activeAppUrl": "/console/manager", + "hasRole": "ROLE_SUPERUSER" + } + ] + } + ] +} diff --git a/src/header.ce.vue b/src/header.ce.vue index e23024d..8148856 100644 --- a/src/header.ce.vue +++ b/src/header.ce.vue @@ -3,39 +3,37 @@ import { computed, onMounted, reactive } from 'vue' import { getUserDetails, getPlatformInfos } from './auth' import type { User, PlatformInfos } from './auth' import UserIcon from './ui/UserIcon.vue' -import GeorchestraLogo from './ui/GeorchestraLogo.vue' -import CatalogIcon from '@/ui/CatalogIcon.vue' -import MapIcon from '@/ui/MapIcon.vue' -import ChartPieIcon from '@/ui/ChartPieIcon.vue' -import UsersIcon from '@/ui/UsersIcon.vue' +import GeorchestraLogo from '@/ui/GeorchestraLogo.vue' import ChevronDownIcon from '@/ui/ChevronDownIcon.vue' -import { LANG_2_TO_3_MAPPER, t } from '@/i18n' +import { getI18n, t } from '@/i18n' +import type { Link, Separator, Dropdown, Config } from '@/config-interfaces' +import { defaultMenu, defaultConfig } from '@/default-config.json' const props = defineProps<{ - hideLogin?: string - lang?: string activeApp?: string - logoUrl?: string - //legacy option : using old iframe option + configFile?: string + stylesheet?: string + height?: number legacyHeader?: string legacyUrl?: string - style?: string - stylesheet?: string + logoUrl?: string }>() const state = reactive({ user: null as null | User, mobileMenuOpen: false, - lang3: props.lang, platformInfos: null as null | PlatformInfos, + menu: defaultMenu as (Link | Separator | Dropdown)[], + config: defaultConfig as Config, + lang3: 'eng', + loaded: false, + matchedRouteScore: 0, + activeAppUrl: '' as string | undefined, }) const isAnonymous = computed(() => !state.user || state.user.anonymous) -const isAdmin = computed(() => state.user?.adminRoles?.admin) const isWarned = computed(() => state.user?.warned) const remainingDays = computed(() => state.user?.remainingDays) -const adminRoles = computed(() => state.user?.adminRoles) - const loginUrl = computed(() => { const current = new URL(window.location.href) current.searchParams.set('login', '') @@ -43,17 +41,101 @@ const loginUrl = computed(() => { }) const logoutUrl = computed(() => '/logout') -function toggleMenu(): void { - state.mobileMenuOpen = !state.mobileMenuOpen +function checkCondition(item: Link | Separator | Dropdown): boolean { + const hasRole = item.hasRole + if (!state.user) return false + if (!hasRole) return true + const isBlocked = item.blockedRole + ?.split(',') + .some(c => state.user?.roles?.indexOf(c) !== -1) + if (isBlocked) return false + return hasRole.split(',').some(c => state.user?.roles?.indexOf(c) !== -1) +} + +function replaceUrlsVariables(url: string): string { + return url.replace(/:lang3/, state.lang3) +} + +function determineActiveApp(): void { + const tmp = allNodes(state.menu, 'activeAppUrl') + const computedUrl = window.location.href.substring( + window.location.origin.length, + window.location.href.length + ) + let matched: boolean + for (const link of tmp) { + matched = false + const activeAppUrlSplitted = link.split(':') + const base = + activeAppUrlSplitted.length > 1 ? activeAppUrlSplitted[0] : 'start' + const url = replaceUrlsVariables( + activeAppUrlSplitted.length > 1 + ? activeAppUrlSplitted[1] + : activeAppUrlSplitted[0] + ) + switch (base) { + case 'end': + matched = computedUrl.endsWith(url) + break + case 'includes': + matched = computedUrl.includes(url) + break + case 'exact': + matched = computedUrl === url + break + default: + matched = computedUrl.startsWith(url) + break + } + state.matchedRouteScore = + matched && link.length > state.matchedRouteScore + ? link.length + : state.matchedRouteScore + if (matched && state.matchedRouteScore === link?.length) { + state.activeAppUrl = link + } + } +} + +function allNodes(obj: any, key: string, array?: any[]): string[] { + array = array || [] + if ('object' === typeof obj) { + for (let k in obj) { + if (k === key) { + array.push(obj[k]) + } else { + allNodes(obj[k], key, array) + } + } + } + return array +} + +function setI18nAndActiveApp(i18n?: any) { + state.lang3 = getI18n( + i18n || {}, + state.config.lang || navigator.language.substring(0, 2) || 'en' + ) + determineActiveApp() + state.loaded = true } onMounted(() => { - state.lang3 = - LANG_2_TO_3_MAPPER[props.lang || navigator.language.substring(0, 2)] || - 'eng' getUserDetails().then(user => { state.user = user - if (user?.adminRoles?.admin) { + state.config.stylesheet ??= props.stylesheet + if (props.configFile) + fetch(props.configFile) + .then(res => res.json()) + .then(json => { + state.config = Object.assign({}, state.config, json.config) + if (json.menu) { + state.menu = json.menu + } + setI18nAndActiveApp(json.i18n) + }) + else setI18nAndActiveApp() + if (user.roles.some(role => state.config.adminRoles.includes(role))) { getPlatformInfos().then( platformInfos => (state.platformInfos = platformInfos) ) @@ -62,125 +144,142 @@ onMounted(() => { }) + {{ t('remaining_days_msg_part1') }} {{ remainingDays }} @@ -208,20 +307,23 @@ onMounted(() => { > {{ t('login') }} -
+
- - + geOrchestra logo - +
@@ -267,9 +366,9 @@ onMounted(() => { `${state.user?.firstname} ${state.user?.lastname}` }} - logout + {{ t('logout') }}
- login + {{ t('login') }}
@@ -307,33 +406,43 @@ onMounted(() => { .nav-item-mobile { @apply text-xl block text-center py-3 w-full border-b border-b-slate-300 first-letter:capitalize; } + .nav-item { - @apply relative text-lg w-fit block after:hover:scale-x-[82%] px-2 mx-2 hover:text-black first-letter:capitalize; + @apply relative text-lg w-fit block after:hover:scale-x-100 lg:mx-3 md:mx-2 hover:text-black first-letter:capitalize text-base; } + .nav-item:after { - @apply block content-[''] absolute h-[3px] bg-gradient-to-r from-primary to-secondary-light w-full scale-x-0 transition duration-300 origin-left; + @apply block content-[''] absolute h-[3px] bg-primary w-full scale-x-0 transition duration-300 origin-left; } + .nav-item.active { - @apply after:scale-x-[82%] after:bg-primary after:bg-none text-gray-900; + @apply after:scale-x-100 after:bg-primary after:bg-none text-gray-900; } + .btn { @apply px-4 py-2 mx-2 text-slate-100 bg-primary rounded hover:bg-slate-700 transition-colors first-letter:capitalize; } + .link-btn { @apply text-primary hover:text-slate-700 hover:underline underline-offset-8 decoration-2 decoration-slate-700 flex flex-col items-center; } + .admin-dropdown > li { @apply block text-center hover:bg-primary-light text-gray-700 hover:text-black capitalize; } + .admin-dropdown > li > a { @apply block w-full h-full py-3; } + .admin-dropdown > li.active { @apply bg-primary-light; } + .icon-dropdown { @apply w-4 h-4 inline-block align-text-top; } + * { -webkit-tap-highlight-color: transparent; } diff --git a/src/i18n.ts b/src/i18n.ts index 00f39f0..5c95b75 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -7,21 +7,29 @@ import ru from './i18n/ru.json' import pt from './i18n/pt.json' import { createI18n } from 'vue-i18n' -const i18n = createI18n({ - locale: navigator.language, - fallbackLocale: 'fr', - messages: { - en: en, - de: de, - fr: fr, - es: es, - nl: nl, - ru: ru, - pt: pt, - }, -}) +export let i18n: any -export const t = i18n.global.t.bind(i18n.global) +export const getI18n = (messages: any, lang: string) => { + i18n = createI18n({ + locale: navigator.language.substring(0, 2), + fallbackLocale: 'en', + messages: { + en: { ...en, ...messages.en }, + de: { ...de, ...messages.de }, + fr: { ...fr, ...messages.fr }, + es: { ...es, ...messages.es }, + nl: { ...nl, ...messages.nl }, + ru: { ...ru, ...messages.ru }, + pt: { ...pt, ...messages.pt }, + }, + }) + i18n.global.locale = lang + return LANG_2_TO_3_MAPPER[lang] +} + +export const t = (msg?: string) => { + return i18n?.global.t(msg) +} export const LANG_2_TO_3_MAPPER: { [index: string]: any } = { en: 'eng', diff --git a/src/ui/GeorchestraLogo.vue b/src/ui/GeorchestraLogo.vue index 4994f86..e1b175f 100644 --- a/src/ui/GeorchestraLogo.vue +++ b/src/ui/GeorchestraLogo.vue @@ -1,5 +1,6 @@ +