WIP: frontend module federation setup.

Initial work to accept future user related apps.
This commit is contained in:
Antoine Nguyen
2025-02-14 13:39:08 +01:00
parent 604a91caa9
commit afe3253fc8
13 changed files with 217 additions and 24 deletions
+1
View File
@@ -23,6 +23,7 @@ node_modules
/static/dist
/frontend/webpack-stats.json
/frontend/src/translations.json
/frontend/.__mf__temp/
# TOX stuff
test_project/modoboa.db
+6 -3
View File
@@ -9,14 +9,15 @@ services:
context: .
dockerfile: docker/Dockerfile.dev
environment:
PYTHONPATH: ".."
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: "..:/modoboa_user"
command: >
sh -c "cd test_project; python3 manage.py rqworker dkim modoboa"
volumes:
- .:/code
- ../modoboa-user:/modoboa_user
api:
container_name: modoboa_api
@@ -29,10 +30,10 @@ services:
env_file:
- test_project/oidc_key
environment:
PYTHONPATH: ".."
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: "..:/modoboa_user"
command: >
sh -c "cd test_project
python3 manage.py migrate
@@ -41,6 +42,7 @@ services:
python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt"
volumes:
- .:/code
- ../modoboa-user:/modoboa_user
api-unsecured:
container_name: modoboa_api_unsecured
@@ -53,15 +55,16 @@ services:
env_file:
- test_project/oidc_key
environment:
PYTHONPATH: ".."
DB: "SQLITE"
DEBUG: "True"
REDIS_HOST: redis
PYTHONPATH: "..:/modoboa_user"
command: >
sh -c "cd test_project
python3 manage.py runserver_plus 0.0.0.0:8001"
volumes:
- .:/code
- ../modoboa-user:/modoboa_user
front:
container_name: modoboa_front
+3 -2
View File
@@ -1,6 +1,7 @@
FROM python:3.12-alpine3.20 AS base
LABEL org.opencontainers.image.authors="Antoine Nguyen <tonio@ngyn.org>"
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
@@ -12,11 +13,11 @@ RUN apk add --update openssl librrd python3-dev libffi-dev gcc g++ musl-dev libx
RUN python3 -m venv $VIRTUAL_ENV
WORKDIR /tmp
COPY requirements.txt /tmp
COPY user-requirements.txt /tmp
COPY test-requirements.txt /tmp
COPY dev-requirements.txt /tmp
RUN python -m pip install -U pip
RUN pip install -r requirements.txt -r test-requirements.txt -r dev-requirements.txt
RUN pip install -r requirements.txt -r user-requirements.txt -r test-requirements.txt -r dev-requirements.txt
FROM base AS run
COPY docker/doveadm /usr/local/bin
RUN chmod +x /usr/local/bin/doveadm
+3
View File
@@ -114,4 +114,7 @@ export default {
`${resource}/filtersets/${filterSetName}/filters/${filter}/`
)
},
getAvailableApplications() {
return repository.get(`${resource}/available_applications/`)
},
}
@@ -0,0 +1,3 @@
<template>
<v-btn icon="mdi-apps" flat> </v-btn>
</template>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="top-menu">
<v-menu activator="parent" location="bottom">
<template #activator="{ props }">
<v-btn icon="mdi-apps" flat class="mr-4" v-bind="props"></v-btn>
</template>
<v-card min-width="350" max-width="400" class="pa-4">
<v-row class="justify-center">
<v-col
v-for="application in applications"
:key="application.name"
cols="6"
>
<v-sheet
rounded
class="application text-center pa-4 text-body-2"
@click="$router.push(application.url)"
>
<v-icon color="primary" :icon="application.icon" size="x-large" />
<div class="mt-2">{{ application.label }}</div>
</v-sheet>
</v-col>
</v-row>
</v-card>
</v-menu>
<v-btn icon flat color="primary">
{{ userInitials }}
<v-menu activator="parent" location="bottom">
<v-card min-width="300" max-width="350">
<v-list>
<div class="text-center">
<v-avatar color="primary">
<span class="text-h5">{{ userInitials }}</span>
</v-avatar>
<v-list-item :title="user.username" />
</div>
<v-divider></v-divider>
<template v-if="remote">
<v-list-item href="https://localhost:3000/account">
<template #prepend>
<v-icon icon="mdi-account-circle-outline"></v-icon>
</template>
<v-list-item-title>Account</v-list-item-title>
</v-list-item>
<v-list-item @click="logout">
<template #prepend>
<v-icon icon="mdi-logout"></v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-card>
</v-menu>
</v-btn>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getActivePinia } from 'pinia'
import accountApi from '@/api/account'
const props = defineProps({
user: {
type: Object,
default: null,
},
remote: {
type: Boolean,
default: false,
},
})
const applications = ref([])
const userInitials = computed(() => {
return props.user.first_name && props.user.last_name
? `${props.user.first_name[0].toUpperCase()}${props.user.last_name[0].toUpperCase()}`
: props.user.username.slice(0, 2).toUpperCase()
})
async function logout() {
getActivePinia()._s.forEach(async (store) => await store.$reset())
}
onMounted(() => {
accountApi.getAvailableApplications().then((resp) => {
applications.value = resp.data
})
})
</script>
<style scoped lang="scss">
.top-menu {
position: fixed;
top: 10px;
right: 10px;
}
.application {
cursor: pointer;
}
.application:hover {
background-color: rgb(var(--v-theme-surface));
}
</style>
+2 -2
View File
@@ -1,5 +1,5 @@
<template>
<v-fab app icon flat location="top right" color="primary">
<v-btn icon flat color="primary">
{{ userInitials }}
<v-menu activator="parent" location="bottom">
<v-card min-width="300" max-width="350">
@@ -28,7 +28,7 @@
</v-list>
</v-card>
</v-menu>
</v-fab>
</v-btn>
</template>
<script setup>
+24 -2
View File
@@ -11,7 +11,7 @@
src="@/assets/Modoboa_RVB-BLANC-SANS.png"
max-width="190"
class="logo"
@click="router.push({ name: 'Dashboard' })"
@click="router.push(props.logoRoute)"
/>
<v-btn
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
@@ -24,8 +24,20 @@
<v-list nav>
<template v-for="item in menuItems" :key="item.text">
<template v-if="displayMenuItem(item)">
<v-list-subheader v-if="item.subheader">
{{ item.text.toUpperCase() }}
</v-list-subheader>
<v-list-item
v-if="!item.children"
v-else-if="item.action"
:value="item"
:exact="item.exact"
:title="item.text"
:prepend-icon="item.icon"
@click="item.action"
>
</v-list-item>
<v-list-item
v-else-if="!item.children"
:value="item"
:to="item.to"
link
@@ -124,6 +136,10 @@ const props = defineProps({
required: false,
default: null,
},
logoRoute: {
type: Object,
default: null,
},
})
const globalStore = useGlobalStore()
@@ -358,6 +374,12 @@ onMounted(() => {
}
}
.v-list-subheader {
&__text {
color: white !important;
}
}
.logo {
cursor: pointer;
}
+4 -1
View File
@@ -37,10 +37,13 @@ export default defineConfig({
name: 'coretools',
filename: 'remoteEntry.js',
exposes: {
'./api-repository': './src/api/repository.js',
'./auth-store': './src/stores/auth.store.js',
'./auth-callback': './src/views/login/LoginCallbackView.vue',
'./navbar': './src/layouts/dashboard/NavBar.vue',
'./user-menu': './src/components/shared/UserMenu.vue',
'./top-menu': './src/components/shared/TopMenu.vue',
'./menu-items': './src/components/tools/MenuItems.vue',
'./form-rules': './src/plugins/rules.js',
},
shared: ['vue', 'pinia', 'vue-router', 'vue3-gettext'],
}),
+9
View File
@@ -531,3 +531,12 @@ class NotificationSerializer(serializers.Serializer):
url = serializers.CharField(required=False)
text = serializers.CharField()
level = serializers.CharField()
class ModoboaApplicationSerializer(serializers.Serializer):
label = serializers.CharField()
name = serializers.CharField()
icon = serializers.CharField()
url = serializers.CharField()
description = serializers.CharField(required=False)
+24
View File
@@ -1,5 +1,7 @@
"""Core API v2 viewsets."""
from django.utils.translation import gettext as _
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from drf_spectacular.utils import extend_schema
from rest_framework import filters, permissions, response, viewsets, mixins
@@ -10,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
from modoboa.admin.api.v1 import serializers as admin_v1_serializers
from modoboa.core.api.v1 import serializers as core_v1_serializers
from modoboa.core.api.v1 import viewsets as core_v1_viewsets
from modoboa.core.extensions import exts_pool
from modoboa.lib import pagination
from modoboa.lib.permissions import IsSuperUser
from modoboa.lib.throttle import GetThrottleViewsetMixin
@@ -130,6 +133,27 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
return response.Response(status=204)
return response.Response({"tokens": tokens})
@extend_schema(
description="Get the list of available Modoboa applications for current user",
responses={200: serializers.ModoboaApplicationSerializer},
)
@action(methods=["get"], detail=False)
def available_applications(self, request):
apps = []
if request.user.role != "SimpleUsers":
apps.append(
{
"name": "admin",
"label": _("Administration"),
"icon": "mdi-toolbox",
"description": _("Administration console"),
"url": "/admin",
}
)
apps += exts_pool.get_available_apps()
serializer = serializers.ModoboaApplicationSerializer(apps, many=True)
return response.Response(serializer.data)
class LogViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet):
"""Log viewset."""
+27 -14
View File
@@ -1,12 +1,14 @@
"""Extension management."""
from typing import Dict, Optional
from django.conf import settings
from django.urls import include
from django.urls import re_path
from django.utils.encoding import smart_str
class ModoExtension(object):
class ModoExtension:
"""
Base extension class.
@@ -14,14 +16,17 @@ class ModoExtension(object):
considered as valid.
"""
name = None
label = None
version = "NA"
description = ""
needs_media = False
always_active = False
url = None
topredirection_url = None
name: str
label: str
version: str = "NA"
description: str = ""
needs_media: bool = False
always_active: bool = False
url: Optional[str] = None
topredirection_url: Optional[str] = None
def get_available_apps(self) -> list:
return []
def get_url(self):
"""Return extension base url."""
@@ -50,13 +55,13 @@ class ModoExtension(object):
pass
class ExtensionsPool(object):
class ExtensionsPool:
"""The extensions manager"""
def __init__(self):
self.extensions = {}
def __init__(self) -> None:
self.extensions: Dict[str, Dict] = {}
def register_extension(self, ext, show=True):
def register_extension(self, ext: ModoExtension, show: bool = True) -> None:
"""Register an extension.
:param ext: a class inheriting from ``Extension``
@@ -64,7 +69,7 @@ class ExtensionsPool(object):
"""
self.extensions[ext.name] = {"cls": ext, "show": show}
def get_extension(self, name):
def get_extension(self, name: str) -> Optional[ModoExtension]:
"""Retrieve the current instance of an extension."""
if name not in self.extensions:
return None
@@ -102,6 +107,14 @@ class ExtensionsPool(object):
for ext in settings.MODOBOA_APPS:
self.load_extension(ext)
def get_available_apps(self) -> list:
result: list = []
for ext_name in list(self.extensions.keys()):
ext = self.get_extension(ext_name)
if ext:
result += ext.get_available_apps()
return result
def get_urls(self, category="app"):
"""Get all urls defined by extensions."""
result = []
+2
View File
@@ -102,6 +102,8 @@ MODOBOA_APPS = (
"modoboa.postfix_autoreply",
"modoboa.sievefilters",
# Modoboa extensions here.
"modoboa_user",
"modoboa_user.contacts",
)
try: