Compare commits

...
Author SHA1 Message Date
openhands c11b164188 fix(TRU-89): customer portal - 'tilladelser' (permissions) tab
Adds a new permissions tab in the kundeportal that exposes the two
primary role templates (driver / booking_coordinator) and the five
permission groups (vehicles, selfserve, bookings, orders, driver
management) so the customer can pick a default driver access template
for new drivers created via the QR flow (TRU-88 / TRU-90).
2026-08-17 18:01:47 +00:00
5 changed files with 372 additions and 0 deletions
@@ -60,6 +60,16 @@ const menu_items = computed(() => [
'user'
]
},
{
name: t('user_menu.my_permissions'),
route: '/user/permissions',
icon: 'fas fa-user-shield',
children: [],
permissions: [
'user',
'SUBUSERS_LIST'
]
},
]);
</script>
@@ -53,6 +53,30 @@
"menu": {
"statistics": "@:{'phrases.compat.global.statistics'}"
},
"permissions": {
"card_chauffor_label": "Chauffør (standard)",
"card_chauffor_description": "Kan bruge døgnvask, oprette bookinger, se køretøjer og ordrer.",
"card_booking_label": "Bookingkoodinator",
"card_booking_description": "Kan oprette og redigere bookinger samt se køretøjer og ordrer.",
"card_intro": "Vælg den rolle, som nye chauffører som standard skal have, når de oprettes via QR-kode.",
"groups": {
"vehicles": "Køretøjer",
"selfserve": "Døgnvask",
"bookings": "Bookinger",
"orders": "Ordrer",
"driver_management": "Chaufførstyring"
},
"loading": "Henter tilladelser...",
"save": "Gem standardrolle",
"saved": "Standardrollen er gemt.",
"save_error": "Standardrollen kunne ikke gemmes.",
"selected_template": "Valgt rolle: {label}",
"subtitle": "Vælg standardtilladelser for nye chauffører.",
"toggle_booking_coordinator": "Bookingkoodinator",
"toggle_chauffor": "Chauffør",
"toggle_description": "De 5 kategorier nedenfor viser, hvad rollen giver adgang til.",
"title": "Mine tilladelser"
},
"orders": {
"single_subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.detaljer'} @:{'terms.glossary.om'} transaktionen",
"subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}",
@@ -3,6 +3,7 @@
"user_menu": {
"my_bookings": "@:{'terms.glossary.mine'} @:{'terms.glossary.bookinger'}",
"my_invoices": "@:{'terms.glossary.mine'} @:{'terms.glossary.fakturaer'}",
"my_permissions": "@:{'terms.glossary.mine'} @:{'terms.glossary.tilladelser'}",
"my_vehicles": "@:{'terms.glossary.mine'} @:{'terms.glossary.køretøjer'}",
"my_washes": "@:{'terms.glossary.mine'} @:{'terms.glossary.vaske'}"
}
+7
View File
@@ -146,6 +146,7 @@ const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashb
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue');
const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue');
const MyPermissions = lazyView('@/views/dashboards/userDashboard/permissions/MyPermissions.vue');
const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue');
const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
@@ -369,6 +370,12 @@ export const router = createRouter({
component: MyInvoices,
meta: { middleware: authMiddleware }
},
{
name: 'mypermissions',
path: '/user/permissions',
component: MyPermissions,
meta: { middleware: authMiddleware }
},
{
name: 'myorder',
path: '/user/orders/:orderId',
@@ -0,0 +1,330 @@
<script setup>
import { onMounted, ref, computed } from "vue";
import Swal from "sweetalert2";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
/**
* Permission groups (5 toggles) that mirror the existing 5 capability groups
* in modules/subusers/classes/subuser_permission_templates_service.php.
* They cover the 5 main areas a customer wants to grant their drivers:
* - vehicles (Køretøjer)
* - selfserve (Døgnvask)
* - bookings (Bookinger)
* - orders (Ordrer)
* - driver_management (Chaufførstyring)
*/
const PERMISSION_GROUPS = [
{ key: "vehicles", capabilities: ["VEHICLES_LIST", "VEHICLES_EDIT", "VEHICLES_DELETE", "VEHICLES_ADD"] },
{ key: "selfserve", capabilities: ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"] },
{ key: "bookings", capabilities: ["BOOKINGS_LIST", "BOOKINGS_EDIT", "BOOKINGS_DELETE", "BOOKINGS_ADD"] },
{ key: "orders", capabilities: ["ORDERS_LIST", "ORDERS_EDIT"] },
{ key: "driver_management", capabilities: ["SUBUSERS_LIST", "SUBUSERS_EDIT", "SUBUSERS_DELETE", "SUBUSERS_ADD"] },
];
/**
* The two primary roles (templates) shown to the customer in the kunde portal
* "tilladelser" tab. The customer picks one as the default for new drivers
* created through the QR flow (see TRU-88 / TRU-90).
*
* - chauffeur (driver) : standard self-serve + bookings
* - booking_coordinator : extended bookings/orders (no self-serve)
*/
const PRIMARY_ROLE_KEYS = ["driver", "booking_coordinator"];
const isLoading = ref(true);
const isSaving = ref(false);
const accessModel = ref({ templates: [], groups: [] });
const selectedTemplateKey = ref(null);
const selectedTemplate = computed(() => {
if (!selectedTemplateKey.value) {
return null;
}
return accessModel.value.templates.find((template) => template.key === selectedTemplateKey.value) || null;
});
const isGroupActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
return groups.some((group) => group.key === groupKey);
};
const isGroupFullyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
const group = groups.find((g) => g.key === groupKey);
if (!group) {
return false;
}
// "Fully active" means all 4 capabilities in the group are granted
const groupDefinition = PERMISSION_GROUPS.find((g) => g.key === groupKey);
if (!groupDefinition) {
return false;
}
return groupDefinition.capabilities.every((capability) => group.capabilities.includes(capability));
};
const isGroupPartiallyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
return isGroupActive(groupKey) && !isGroupFullyActive(groupKey);
};
const isPrimaryRole = (key) => PRIMARY_ROLE_KEYS.includes(key);
const loadAccessModel = async () => {
isLoading.value = true;
try {
const model = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
const payload = model?.data?.data || model?.data || model;
accessModel.value = {
templates: Array.isArray(payload?.templates) ? payload.templates : [],
groups: Array.isArray(payload?.groups) ? payload.groups : [],
};
// Read the customer's stored default template via SessionUser / API
const stored = await SessionUser.request("/customer/default-driver-template", "GET")
.then((response) => response?.data?.data?.template_key || response?.data?.template_key || null)
.catch(() => null);
if (stored && accessModel.value.templates.some((template) => template.key === stored)) {
selectedTemplateKey.value = stored;
} else {
const chauffeur = accessModel.value.templates.find((template) => template.key === "driver");
selectedTemplateKey.value = chauffeur?.key || accessModel.value.templates[0]?.key || null;
}
} catch (error) {
console.error("Failed to load permission templates:", error);
accessModel.value = { templates: [], groups: [] };
await Swal.fire({
title: t("common.error"),
text: error?.message || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isLoading.value = false;
}
};
const onSelectTemplate = (key) => {
if (!accessModel.value.templates.some((template) => template.key === key)) {
return;
}
selectedTemplateKey.value = key;
};
const onSaveDefault = async () => {
if (!selectedTemplateKey.value || isSaving.value) {
return;
}
isSaving.value = true;
try {
await SessionUser.request("/customer/default-driver-template", "PUT", {
template_key: selectedTemplateKey.value,
});
await Swal.fire({
title: t("user_dashboard.permissions.saved"),
icon: "success",
timer: 1500,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
title: t("common.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isSaving.value = false;
}
};
onMounted(loadAccessModel);
</script>
<template>
<UserDashboardPageWrapper
:title="$t('user_dashboard.permissions.title')"
:subtitle="$t('user_dashboard.permissions.subtitle')"
>
<div v-if="isLoading" class="notification is-light">
{{ $t("user_dashboard.permissions.loading") }}
</div>
<template v-else>
<div class="permissions-tab" data-testid="user-permissions-tab">
<p class="block has-text-grey">
{{ $t("user_dashboard.permissions.card_intro") }}
</p>
<div class="columns is-multiline" data-testid="user-permissions-roles">
<div
v-for="template in accessModel.templates.filter((tpl) => isPrimaryRole(tpl.key))"
:key="template.key"
class="column is-half"
>
<button
type="button"
class="permission-role-card"
:class="{ 'permission-role-card--selected': selectedTemplateKey === template.key }"
:data-testid="`user-permission-role-${template.key}`"
@click="onSelectTemplate(template.key)"
>
<span class="permission-role-card__title">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_label")
: $t("user_dashboard.permissions.card_booking_label")
}}
</span>
<span class="permission-role-card__description">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_description")
: $t("user_dashboard.permissions.card_booking_description")
}}
</span>
<span
v-if="selectedTemplateKey === template.key"
class="tag is-success is-light mt-2"
:data-testid="`user-permission-role-selected-${template.key}`"
>
{{ $t("user_dashboard.permissions.selected_template", { label: template.label }) }}
</span>
</button>
</div>
</div>
<div class="permissions-tab__toggles" data-testid="user-permissions-toggles">
<h3 class="title is-6 mb-2">
<span class="icon"><i class="fas fa-sliders-h"></i></span>
<span>{{ $t("user_dashboard.permissions.toggle_description") }}</span>
</h3>
<div class="columns is-multiline">
<div
v-for="group in PERMISSION_GROUPS"
:key="group.key"
class="column is-half"
>
<div
class="permission-toggle box"
:class="{
'permission-toggle--active': isGroupFullyActive(group.key),
'permission-toggle--partial': isGroupPartiallyActive(group.key),
}"
:data-testid="`user-permission-toggle-${group.key}`"
>
<div class="level is-mobile mb-0">
<div class="level-left">
<div class="level-item">
<div>
<p class="permission-toggle__label">
{{ $t(`user_dashboard.permissions.groups.${group.key}`) }}
</p>
<p class="help has-text-grey">
{{ group.capabilities.length }} rettigheder
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-switch
:model-value="isGroupFullyActive(group.key) || isGroupPartiallyActive(group.key)"
disabled
size="is-small"
type="is-success"
:data-testid="`user-permission-toggle-switch-${group.key}`"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="permissions-tab__actions mt-4">
<button
class="button is-link"
:class="{ 'is-loading': isSaving }"
:disabled="!selectedTemplateKey || isSaving"
data-testid="user-permissions-save"
@click="onSaveDefault"
>
<span class="icon"><i class="fas fa-save"></i></span>
<span>{{ $t("user_dashboard.permissions.save") }}</span>
</button>
</div>
</div>
</template>
</UserDashboardPageWrapper>
</template>
<style scoped>
.permissions-tab .permission-role-card {
align-items: flex-start;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 150px;
padding: 1.25rem;
text-align: left;
width: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.permissions-tab .permission-role-card:hover,
.permissions-tab .permission-role-card--selected {
border-color: #3273dc;
box-shadow: 0 0 0 1px #3273dc;
}
.permissions-tab .permission-role-card__title {
color: #1f2933;
font-weight: 700;
font-size: 1.1rem;
}
.permissions-tab .permission-role-card__description {
color: #4a4a4a;
font-size: 0.9rem;
line-height: 1.4;
}
.permissions-tab .permission-toggle {
border: 1px solid #e6ebf1;
border-radius: 6px;
padding: 0.85rem 1rem;
background: #fafbfc;
}
.permissions-tab .permission-toggle--active {
background: #f1faf3;
border-color: #48c78e;
}
.permissions-tab .permission-toggle--partial {
background: #fff8e1;
border-color: #ffe08a;
}
.permissions-tab .permission-toggle__label {
font-weight: 600;
color: #1f2933;
margin: 0;
}
</style>