diff --git a/.gitignore b/.gitignore index 94220ca0..7614b44b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ venv/ # production env .prod.env +.sso.env # generated documentation gen @@ -30,3 +31,7 @@ web-app/.node-version mergin-db-ce docker-compose.local.yml + +# SSO +*.pem +*.crt diff --git a/server/mergin/version.py b/server/mergin/version.py index a87af32c..d8556b12 100644 --- a/server/mergin/version.py +++ b/server/mergin/version.py @@ -4,4 +4,4 @@ def get_version(): - return "2025.4.1" + return "2025.4.2" diff --git a/server/setup.py b/server/setup.py index db732ec7..f6f32998 100644 --- a/server/setup.py +++ b/server/setup.py @@ -6,7 +6,7 @@ setup( name="mergin", - version="2025.4.1", + version="2025.4.2", url="https://github.com/MerginMaps/mergin", license="AGPL-3.0-only", author="Lutra Consulting Limited", diff --git a/web-app/packages/admin-app/src/App.vue b/web-app/packages/admin-app/src/App.vue index 63d31509..cc7dff16 100644 --- a/web-app/packages/admin-app/src/App.vue +++ b/web-app/packages/admin-app/src/App.vue @@ -55,11 +55,12 @@ import { useNotificationStore, useUserStore, useLayoutStore, - useProjectStore + useProjectStore, + useRouterTitle } from '@mergin/lib' import { mapActions, mapState } from 'pinia' import { useToast } from 'primevue/usetoast' -import { defineComponent } from 'vue' +import { defineComponent, watchEffect } from 'vue' import { useMeta } from 'vue-meta' export default defineComponent({ @@ -120,8 +121,11 @@ export default defineComponent({ } }, setup() { + const { title } = useRouterTitle({ + defaultTitle: 'Mergin Maps Admin Panel' + }) useMeta({ - title: 'Mergin Maps', + title: 'Mergin Maps Admin Panel', meta: [ { name: 'description', @@ -144,6 +148,9 @@ export default defineComponent({ notificationStore.init(toast) layoutStore.init() projectStore.filterPermissions(['editor'], ['edit']) + watchEffect(() => { + document.title = title.value + }) }, async created() { await this.fetchConfig() diff --git a/web-app/packages/admin-app/src/router.ts b/web-app/packages/admin-app/src/router.ts index df1a3152..b30fe77a 100644 --- a/web-app/packages/admin-app/src/router.ts +++ b/web-app/packages/admin-app/src/router.ts @@ -13,7 +13,8 @@ import { ProjectSettingsView, ProjectVersionView, ProjectVersionsView, - OverviewView + OverviewView, + getAdminTitle } from '@mergin/admin-lib' import { NotFoundView, @@ -165,5 +166,10 @@ export const createRouter = (pinia: Pinia) => { routeUtils.isSuperUser(to, from, next, userStore) }) + router.beforeEach((to, from, next) => { + // Set the page title based on the route's meta title + to.meta.title = getAdminTitle + next() + }) return router } diff --git a/web-app/packages/admin-app/src/shims-vue-router.d.ts b/web-app/packages/admin-app/src/shims-vue-router.d.ts index 9cbc6fc4..1a925be0 100644 --- a/web-app/packages/admin-app/src/shims-vue-router.d.ts +++ b/web-app/packages/admin-app/src/shims-vue-router.d.ts @@ -2,8 +2,6 @@ import 'vue-router' declare module 'vue-router' { interface RouteMeta { - public?: boolean - allowedForNoWorkspace?: boolean - breadcrump?: { title: string; path: string }[] + title?: string | string[] | ((route, extened) => string | string[]) } } diff --git a/web-app/packages/admin-lib/src/modules/admin/routes.ts b/web-app/packages/admin-lib/src/modules/admin/routes.ts index c4835066..3439a561 100644 --- a/web-app/packages/admin-lib/src/modules/admin/routes.ts +++ b/web-app/packages/admin-lib/src/modules/admin/routes.ts @@ -2,7 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -import { RouteRecord } from 'vue-router' +import { RouteLocationNormalizedLoaded, RouteRecord } from 'vue-router' + +import { AdminRouteParams } from './types' export enum AdminRoutes { ACCOUNTS = 'accounts', @@ -15,7 +17,27 @@ export enum AdminRoutes { ProjectHistory = 'project-versions', ProjectSettings = 'project-settings', ProjectVersion = 'project-version', - FileVersionDetail = 'file-version-detail' + FileVersionDetail = 'file-version-detail', + Login = 'login' +} + +export const getAdminTitle = (route: RouteLocationNormalizedLoaded) => { + const params = route.params as AdminRouteParams + const titles: Record = { + [AdminRoutes.Login]: ['Sign in', 'Mergin Maps Admin Panel'], + [AdminRoutes.ACCOUNTS]: 'Accounts', + [AdminRoutes.ACCOUNT]: 'Account details', + [AdminRoutes.OVERVIEW]: 'Admin overview', + [AdminRoutes.PROJECTS]: 'Projects', + [AdminRoutes.PROJECT]: ['Details', params.projectName], + [AdminRoutes.SETTINGS]: 'Settings', + [AdminRoutes.ProjectTree]: ['Files', params.projectName], + [AdminRoutes.ProjectHistory]: ['History', params.projectName], + [AdminRoutes.ProjectSettings]: ['Settings', params.projectName], + [AdminRoutes.ProjectVersion]: [params.version_id, params.projectName], + [AdminRoutes.FileVersionDetail]: [params.path, params.version_id] + } + return titles[route.name as AdminRoutes] } export const getRoutes = (): RouteRecord[] => [] diff --git a/web-app/packages/admin-lib/src/modules/admin/types.ts b/web-app/packages/admin-lib/src/modules/admin/types.ts index ce706c09..353d94df 100644 --- a/web-app/packages/admin-lib/src/modules/admin/types.ts +++ b/web-app/packages/admin-lib/src/modules/admin/types.ts @@ -84,4 +84,12 @@ export interface DownloadReportParams { date_to: string } +export interface AdminRouteParams { + namespace?: string + projectName?: string + version_id?: string + path?: string + username?: string +} + /* eslint-enable camelcase */ diff --git a/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue b/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue index ae739a43..f4642a93 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue @@ -62,6 +62,8 @@ + + @@ -74,7 +76,9 @@ import { useNotificationStore, ProjectVersion, errorUtils, - ProjectVersionChanges + ProjectVersionChanges, + DownloadProgress, + DownloadFileLarge } from '@mergin/lib' import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' diff --git a/web-app/packages/admin-lib/src/shims-vue-router.d.ts b/web-app/packages/admin-lib/src/shims-vue-router.d.ts new file mode 100644 index 00000000..a7a591d5 --- /dev/null +++ b/web-app/packages/admin-lib/src/shims-vue-router.d.ts @@ -0,0 +1,7 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + title?: string | string[] | ((route, extended) => string | string[]) + } +} diff --git a/web-app/packages/app/shims-vue-router.d.ts b/web-app/packages/app/shims-vue-router.d.ts index 9cbc6fc4..6c1bdb35 100644 --- a/web-app/packages/app/shims-vue-router.d.ts +++ b/web-app/packages/app/shims-vue-router.d.ts @@ -4,6 +4,9 @@ declare module 'vue-router' { interface RouteMeta { public?: boolean allowedForNoWorkspace?: boolean - breadcrump?: { title: string; path: string }[] + breadcrump?: + | { title: string; path: string }[] + | ((route) => { title: string; path: string }[]) + title?: string | string[] | ((route, extended) => string | string[]) } } diff --git a/web-app/packages/app/src/App.vue b/web-app/packages/app/src/App.vue index 1e9da4bc..fe7593d2 100644 --- a/web-app/packages/app/src/App.vue +++ b/web-app/packages/app/src/App.vue @@ -53,11 +53,13 @@ import { useNotificationStore, useUserStore, InstanceMaintenanceMessage, - useProjectStore + useProjectStore, + useRouterTitle, + routeUtils } from '@mergin/lib' import { mapActions, mapState } from 'pinia' import { useToast } from 'primevue/usetoast' -import { defineComponent } from 'vue' +import { defineComponent, watchEffect } from 'vue' import { useMeta } from 'vue-meta' export default defineComponent({ @@ -101,8 +103,11 @@ export default defineComponent({ } }, setup() { + const { title } = useRouterTitle({ + defaultTitle: routeUtils.DEFAULT_PAGE_TITLE + }) useMeta({ - title: 'Mergin Maps', + title: routeUtils.DEFAULT_PAGE_TITLE, meta: [ { name: 'description', @@ -125,6 +130,9 @@ export default defineComponent({ notificationStore.init(toast) layoutStore.init() projectStore.filterPermissions(['editor'], ['edit']) + watchEffect(() => { + document.title = title.value + }) }, async created() { await this.fetchConfig() @@ -152,7 +160,7 @@ export default defineComponent({ }, methods: { ...mapActions(useAppStore, ['setServerError']), - ...mapActions(useInstanceStore, ['fetchPing', 'fetchConfig', 'initApp']), + ...mapActions(useInstanceStore, ['fetchPing', 'fetchConfig']), ...mapActions(useNotificationStore, { notificationError: 'error' }), diff --git a/web-app/packages/app/src/router.ts b/web-app/packages/app/src/router.ts index e8e1522d..4fb00555 100644 --- a/web-app/packages/app/src/router.ts +++ b/web-app/packages/app/src/router.ts @@ -12,7 +12,12 @@ import { routeUtils, useUserStore, SideBarTemplate as SideBar, - ProjectRouteName + ProjectRouteName, + UserRouteName, + DashboardRouteName, + getDashboardTitle, + getUserTitle, + getProjectTitle } from '@mergin/lib' import { Pinia } from 'pinia' import { @@ -57,28 +62,28 @@ export const createRouter = (pinia: Pinia) => { } }, path: '/login/:reset?', - name: 'login', + name: UserRouteName.Login, component: LoginView, props: true, meta: { public: true } }, { path: '/confirm-email/:token', - name: 'confirm_email', + name: UserRouteName.ConfirmEmail, component: VerifyEmailView, props: true, meta: { public: true } }, { path: '/change-password/:token', - name: 'change_password', + name: UserRouteName.ChangePassword, component: ChangePasswordView, props: true, meta: { public: true } }, { path: '/dashboard', - name: 'dashboard', + name: DashboardRouteName.Dashboard, components: { default: DashboardView, header: AppHeader, @@ -93,7 +98,7 @@ export const createRouter = (pinia: Pinia) => { }, { path: '/profile', - name: 'user_profile', + name: UserRouteName.UserProfile, meta: { allowedForNoWorkspace: true, breadcrump: [{ title: 'Profile', path: '/profile' }] @@ -107,7 +112,7 @@ export const createRouter = (pinia: Pinia) => { }, { path: '/projects', - name: 'projects', + name: ProjectRouteName.Projects, components: { default: ProjectsListView, header: AppHeader, @@ -118,13 +123,12 @@ export const createRouter = (pinia: Pinia) => { }, meta: { public: true, - title: 'Projects', breadcrump: [{ title: 'Projects', path: '/projects' }] }, children: [ { path: 'explore', - name: 'explore', + name: ProjectRouteName.ProjectsExplore, component: ProjectsListView, props: true, meta: { @@ -194,75 +198,82 @@ export const createRouter = (pinia: Pinia) => { name: ProjectRouteName.ProjectTree, component: FileBrowserView, props: true, - meta: { public: true } + meta: { + public: true, + breadcrump: (route) => [ + { + title: route.params?.projectName, + path: `/projects/${route.params.namespace}/${route.params.projectName}/tree` + } + ] + } }, { path: 'settings', name: 'project-settings', component: ProjectSettingsView, - props: true + props: true, + meta: { + breadcrump: (route) => [ + { + title: route.params?.projectName, + path: `/projects/${route.params.namespace}/${route.params.projectName}/tree` + } + ] + } }, { path: 'history', name: 'project-versions', component: ProjectVersionsView, - props: true + props: true, + meta: { + breadcrump: (route) => [ + { + title: route.params?.projectName, + path: `/projects/${route.params.namespace}/${route.params.projectName}/tree` + } + ] + } }, { path: 'collaborators', name: ProjectRouteName.ProjectCollaborators, component: ProjectCollaboratorsView, - props: true + props: true, + meta: { + breadcrump: (route) => [ + { + title: route.params?.projectName, + path: `/projects/${route.params.namespace}/${route.params.projectName}/tree` + } + ] + } }, { path: 'history/:version_id/:path', - name: 'file-version-detail', + name: ProjectRouteName.FileVersionDetail, component: FileVersionDetailView, props: true, - meta: { public: true }, - // TODO: refactor to function in utils - beforeEnter(to, from, next) { - to.meta = { - ...to.meta, - breadcrump: [ - { - title: String(to.params.projectName), - path: `/projects/${to.params.namespace}/${to.params.projectName}/history` - }, - { - title: String(to.params.version_id), - path: `/projects/${to.params.namespace}/${to.params.projectName}/history/${to.params.version_id}` - }, - { - title: String(to.params.path), - path: to.fullPath - } - ] - } - next() + meta: { + public: true, + breadcrump: (route) => [ + { + title: route.params?.projectName, + path: `/projects/${route.params.namespace}/${route.params.projectName}/tree` + }, + { + title: route.params?.version_id, + path: `/projects/${route.params.namespace}/${route.params.projectName}/history/${route.params.version_id}` + }, + { + title: route.params?.path, + path: route.fullPath + } + ] } } ] - // Not apply for project version detail , which have own breadcrump - .map((child) => - child.name === 'file-version-detail' - ? child - : { - ...child, - beforeEnter: (to, from, next) => { - to.meta = { - ...to.meta, - breadcrump: [ - { - title: String(to.params.projectName), - path: to.fullPath - } - ] - } - next() - } - } - ) }, { path: '/:pathMatch(.*)*', @@ -277,5 +288,18 @@ export const createRouter = (pinia: Pinia) => { const userStore = useUserStore(pinia) routeUtils.isAuthenticatedGuard(to, from, next, userStore) }) + + router.beforeEach(async (to, from, next) => { + const titleResolvers = [getProjectTitle, getUserTitle, getDashboardTitle] + + // Use `find` to get the first resolved title + to.meta.title = (route) => { + return titleResolvers + .map((resolver) => resolver(route)) + .find((title) => title) + } + + next() + }) return router } diff --git a/web-app/packages/app/src/shims-vue-router.d.ts b/web-app/packages/app/src/shims-vue-router.d.ts index 9cbc6fc4..bce2786d 100644 --- a/web-app/packages/app/src/shims-vue-router.d.ts +++ b/web-app/packages/app/src/shims-vue-router.d.ts @@ -4,6 +4,8 @@ declare module 'vue-router' { interface RouteMeta { public?: boolean allowedForNoWorkspace?: boolean - breadcrump?: { title: string; path: string }[] + breadcrump?: + | { title: string; path: string }[] + | ((route) => { title: string; path: string }[]) } } diff --git a/web-app/packages/lib/src/common/composables/index.ts b/web-app/packages/lib/src/common/composables/index.ts new file mode 100644 index 00000000..dd4fe405 --- /dev/null +++ b/web-app/packages/lib/src/common/composables/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) Lutra Consulting Limited +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +export { default as useRouterTitle } from './use_router_title' diff --git a/web-app/packages/lib/src/common/composables/use_router_title.ts b/web-app/packages/lib/src/common/composables/use_router_title.ts new file mode 100644 index 00000000..89936854 --- /dev/null +++ b/web-app/packages/lib/src/common/composables/use_router_title.ts @@ -0,0 +1,43 @@ +// Copyright (C) Lutra Consulting Limited +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +import { computed, toValue } from 'vue' +import { useRoute } from 'vue-router' + +interface RouterTitleConfig { + defaultTitle?: string | string[] +} + +const SEPARATOR = ' \u2022 ' // Unicode for bullet character + +const useRouterTitle = (config: RouterTitleConfig = {}, extended = {}) => { + const route = useRoute() + + const metaTitle = computed(() => { + const metaTitle = route.meta.title + const defaultTitle = config.defaultTitle + if (!metaTitle) { + return defaultTitle + } + if (typeof metaTitle === 'function') { + return metaTitle(route, toValue(extended)) || defaultTitle + } + return metaTitle + }) + + const title = computed(() => { + const result = metaTitle.value + if (Array.isArray(result)) { + return result.filter(Boolean).join(` ${SEPARATOR} `) + } + return result + }) + + return { + metaTitle, + title + } +} + +export default useRouterTitle diff --git a/web-app/packages/lib/src/common/index.ts b/web-app/packages/lib/src/common/index.ts index 4e3f31f8..87759529 100644 --- a/web-app/packages/lib/src/common/index.ts +++ b/web-app/packages/lib/src/common/index.ts @@ -18,6 +18,7 @@ export * from './errors' export * from './mixins' export * from './http' export * from './types' +export * from './composables' // export * from './router_without_navigation_failure' export * as dateUtils from './date_utils' export * as htmlUtils from './html_utils' diff --git a/web-app/packages/lib/src/common/route_utils.ts b/web-app/packages/lib/src/common/route_utils.ts index d9866194..1c516eb8 100644 --- a/web-app/packages/lib/src/common/route_utils.ts +++ b/web-app/packages/lib/src/common/route_utils.ts @@ -6,6 +6,8 @@ import isEqual from 'lodash/isEqual' import pick from 'lodash/pick' import { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' +export const DEFAULT_PAGE_TITLE = 'Mergin Maps' + export type IsAuthenticatedGuardOptions = { notAuthenticatedRedirectPath?: string } diff --git a/web-app/packages/lib/src/modules/dashboard/routes.ts b/web-app/packages/lib/src/modules/dashboard/routes.ts index 055037c7..22b965e2 100644 --- a/web-app/packages/lib/src/modules/dashboard/routes.ts +++ b/web-app/packages/lib/src/modules/dashboard/routes.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial +import { RouteLocationNormalizedLoaded } from 'vue-router' + /** * Enum for route names in the app's router. * Defines string constants for each route path used by project module. @@ -10,3 +12,17 @@ export enum DashboardRouteName { Dashboard = 'dashboard' } + +export const getDashboardTitle = ( + route: RouteLocationNormalizedLoaded, + extended?: { workspaceName: string } +) => { + const name = route.name as DashboardRouteName + const titles: Record = { + [DashboardRouteName.Dashboard]: [ + 'Dashboard', + extended?.workspaceName + ].filter(Boolean) + } + return titles[name] +} diff --git a/web-app/packages/lib/src/modules/layout/components/AppBreadcrumbs.vue b/web-app/packages/lib/src/modules/layout/components/AppBreadcrumbs.vue index 3ef7239a..5e84fa55 100644 --- a/web-app/packages/lib/src/modules/layout/components/AppBreadcrumbs.vue +++ b/web-app/packages/lib/src/modules/layout/components/AppBreadcrumbs.vue @@ -49,44 +49,51 @@ import { MenuItem } from 'primevue/menuitem' import { computed } from 'vue' import { useRoute } from 'vue-router' -import { useLayoutStore } from '../store' - type EnhancedMenuItem = MenuItem & { path: string; active?: boolean } -const layoutStore = useLayoutStore() const route = useRoute() -// Merge all matched meta.breadcrumps with current route breadcrumps -const items = computed(() => - layoutStore.breadcrumps?.length - ? layoutStore.breadcrumps?.map((item, index, items) => ({ - label: item.title, - path: item.path, - active: index === items.length - 1 - })) - : [ - ...route.matched.reduce((acc, curr) => { - if (curr.name === route.name) return acc +function parseBreadcrump(item: { + title: string + path: string +}): EnhancedMenuItem { + return { + label: item.title, + path: item.path + } +} - return [ - ...acc, - ...(curr.meta?.breadcrump ?? []).map((item) => ({ - label: item.title, - path: item.path - })) - ] - }, []), - // adding current route wich is not in matched meta - ...(route.meta.breadcrump ?? []).map((item) => ({ - label: item.title, - path: item.path - })) - ] - // last will be active - .map((item, index, items) => ({ - ...item, - active: index === items.length - 1 - })) +const matchedBreacrumps = computed(() => { + return route.matched.reduce((acc, curr) => { + if (curr.name === route.name) return acc + const breadcrumps = + typeof curr.meta?.breadcrump === 'function' + ? curr.meta?.breadcrump(route) + : curr.meta?.breadcrump + return [...acc, ...(breadcrumps ?? []).map(parseBreadcrump)] + }, []) +}) + +const currentRouteBreacrumps = computed(() => { + const breadcrumps = + typeof route.meta?.breadcrump === 'function' + ? route.meta?.breadcrump(route) + : route.meta?.breadcrump + return (breadcrumps ?? []).map(parseBreadcrump) +}) + +// Merge all matched meta.breadcrumps with current route breadcrumps +const items = computed(() => + [ + ...matchedBreacrumps.value, + // adding current route wich is not in matched meta + ...currentRouteBreacrumps.value + ] + // last will be active + .map((item, index, items) => ({ + ...item, + active: index === items.length - 1 + })) ) diff --git a/web-app/packages/lib/src/modules/layout/components/AppHeaderTemplate.vue b/web-app/packages/lib/src/modules/layout/components/AppHeaderTemplate.vue index b04a03c5..1d1501ef 100644 --- a/web-app/packages/lib/src/modules/layout/components/AppHeaderTemplate.vue +++ b/web-app/packages/lib/src/modules/layout/components/AppHeaderTemplate.vue @@ -38,6 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial