Align subuser self-service controls (#234)

## Summary
- Centralize subuser permission capability labels so the grant editor,
table header, and action settings wheel use the same self-service text.
- Add the self-service access control to each grant section in the
subuser action wheel.
- Add focused unit coverage for the shared label helper and subuser
action wheel payload.

## Verification
- `npm run test:unit:single --
tests/unit/subuser-management-labels.spec.js
tests/unit/subuser-grant-permission-nodes.spec.js`
- `npx eslint
src/components/displays/selectors/SubuserGrantPermissionNodes.vue
src/components/displays/superuser/tables/SubusersTable.vue
src/components/session/subuser/subuserPermissionLabels.js
tests/unit/subuser-management-labels.spec.js --quiet`
- `npm run build`

## Visual change previews
### View: Customer subuser management
**Description:** Shows the customer-facing chauffeur table and settings
wheel self-service label aligned to the same wording across table
header, row control, and wheel item.

#### Mobile (390x844)
**Before:** ![Before
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/wb-550fbd4a-subuser-module/visual-previews/subuser-management/mobile-before.png)
**After:** ![After
mobile](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/wb-550fbd4a-subuser-module/visual-previews/subuser-management/mobile-after.png)

#### Tablet (768x1024)
**Before:** ![Before
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/wb-550fbd4a-subuser-module/visual-previews/subuser-management/tablet-before.png)
**After:** ![After
tablet](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/wb-550fbd4a-subuser-module/visual-previews/subuser-management/tablet-after.png)

#### Desktop (1440x900)
**Before:** ![Before
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/wb-550fbd4a-subuser-module/visual-previews/subuser-management/desktop-before.png)
**After:** ![After
desktop](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/wb-550fbd4a-subuser-module/visual-previews/subuser-management/desktop-after.png)

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
This commit is contained in:
Jeppe B
2026-07-28 17:06:36 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent 4f26ddd2cc
commit 7a84cd9162
12 changed files with 240 additions and 29 deletions
@@ -62,6 +62,8 @@ const onInput = async (value) => {
await props.changeAction(nextValue);
}
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
} catch {
localValue.value = normalizeSelectValue(props.value);
} finally {
isProcessing.value = false;
}
@@ -3,6 +3,7 @@ import { BCheckbox } from "buefy";
import { computed, onMounted, ref, watch } from "vue";
import i18n from "@/i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { permissionCapabilityLabel } from "@/components/session/subuser/subuserPermissionLabels.js";
const props = defineProps({
grant: {
@@ -64,27 +65,6 @@ const LEGACY_PERMISSION_GROUPS: PermissionGroup[] = [
},
];
const LEGACY_PERMISSION_LABEL_KEYS: Record<string, string> = {
VEHICLES_LIST: "view_vehicles",
VEHICLES_EDIT: "edit_vehicles",
VEHICLES_DELETE: "delete_vehicles",
VEHICLES_ADD: "add_vehicles",
SELFSERVE_LIST: "view_selfserve",
SELFSERVE_EDIT: "edit_selfserve",
SELFSERVE_DELETE: "delete_selfserve",
SELFSERVE_ADD: "start_selfserve",
BOOKINGS_LIST: "view_bookings",
BOOKINGS_EDIT: "edit_bookings",
BOOKINGS_DELETE: "delete_bookings",
BOOKINGS_ADD: "add_bookings",
ORDERS_LIST: "view_orders",
ORDERS_EDIT: "edit_orders",
SUBUSERS_LIST: "view_drivers",
SUBUSERS_EDIT: "edit_driver_access",
SUBUSERS_DELETE: "disable_driver_access",
SUBUSERS_ADD: "invite_drivers",
};
const templates = ref<PermissionTemplate[]>([]);
const selectedPermissions = ref<string[]>([]);
const selectedTemplateKey = ref<string>(props.templateKey || props.grant.permission_template_key || "custom");
@@ -161,10 +141,7 @@ const templateLabel = (key: string, _fallback = "") => t(`superuser.driver_acces
const templateDescription = (template: PermissionTemplate) =>
t(`superuser.driver_access.templates.${template.key}.description`);
const groupLabel = (key: string) => t(`superuser.driver_access.groups.${key}`);
const permissionLabel = (permission: string) => {
const capabilityKey = LEGACY_PERMISSION_LABEL_KEYS[permission] || permission;
return t(`superuser.driver_access.capabilities.${capabilityKey}`);
};
const permissionLabel = (permission: string) => permissionCapabilityLabel(permission);
watch(
() => props.permissions,
@@ -6,6 +6,7 @@ import { usePaginatedListInstance } from "@/components/pagination/paginatedList.
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { selfServeAccessLabel } from "@/components/session/subuser/subuserPermissionLabels.js";
const SELF_SERVE_PERMISSION_KEYS = ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"];
const DOGNVASK_PERMISSION_KEYS = ["SELFSERVE_LIST", "SELFSERVE_ADD"];
@@ -457,7 +458,7 @@ const buildDognvaskPermissions = (subuser, enabled) => {
const apiErrorMessage = (error, fallback) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || fallback;
const onToggleDognvask = async (subuser, enabled) => {
const updateDognvask = async (subuser, enabled, rejectOnFailure = false) => {
if (isDognvaskDisabled(subuser)) {
return;
}
@@ -474,9 +475,16 @@ const onToggleDognvask = async (subuser, enabled) => {
icon: "error",
confirmButtonText: "Luk",
});
if (rejectOnFailure) {
throw error;
}
}
};
const onToggleDognvask = (subuser, enabled) => updateDognvask(subuser, enabled);
const onToggleDognvaskFromSettingsWheel = (subuser, enabled) =>
updateDognvask(subuser, enabled, true);
const onEditPermissions = async (subuser) => {
if (!subuser?.grant_id) {
return;
@@ -1099,10 +1107,15 @@ const menuSection = (key, label, items) => ({
const driverWheelText = (key, values = undefined) =>
t(`templates.generated.compat.superuser.driver_wheel.${key}`, values);
const selfServeActionLabel = computed(() => selfServeAccessLabel());
const verificationStatusOptions = () => [
{ value: true, label: driverContactText("verified") },
{ value: false, label: driverContactText("unverified") },
];
const booleanStatusOptions = () => [
{ value: true, label: t("common.active") },
{ value: false, label: t("common.inactive") },
];
const contactVerificationStatusItem = (subuser, channel, key, icon) => {
const testId = `subuser-action-${channel}-verification-${key}`;
if (!canManageContactVerification(subuser, channel)) {
@@ -1458,6 +1471,26 @@ const accessItemsForGrant = (grant) => {
testId: `subuser-assign-vehicle-${key}`,
})
: menuLabel("subuser-plate", "fas fa-car-side", "Nummerplade", "", formatPlate(grant)),
canEditPermissions() && grant.grant_id
? menuSelect("subuser-self-serve", {
icon: "fas fa-toolbox",
label: selfServeActionLabel.value,
value: dognvaskEnabled(grant),
options: booleanStatusOptions(),
disabled: isDognvaskDisabled(grant),
changeAction: (enabled) =>
onToggleDognvaskFromSettingsWheel(grant, enabled === true || enabled === "true"),
testId: `subuser-self-serve-${key}`,
})
: grant.grant_id
? menuLabel(
"subuser-self-serve",
"fas fa-toolbox",
selfServeActionLabel.value,
"",
dognvaskEnabled(grant) ? t("common.active") : t("common.inactive")
)
: null,
canEditPermissions() && grant.grant_id
? menuAction("subuser-permissions", {
icon: "fas fa-user-shield",
@@ -1612,7 +1645,7 @@ const actionSections = (subuser) => {
<th>Chauffør</th>
<th>Tlf.</th>
<th>Nummerplade</th>
<th>Kundeadgange</th>
<th>{{ selfServeActionLabel }}</th>
<th class="has-text-right action-column">Handlinger</th>
<th class="has-text-right remove-column"></th>
</tr>
@@ -1796,7 +1829,7 @@ const actionSections = (subuser) => {
</div>
<div class="subusers-table__access-control">
<span class="subusers-table__access-control-label">Døgnvask</span>
<span class="subusers-table__access-control-label">{{ selfServeActionLabel }}</span>
<b-tooltip
v-if="isDognvaskDisabled(grant)"
:label="dognvaskDisabledReason(grant)"
@@ -0,0 +1,32 @@
import i18n from "@/i18n";
export const SELF_SERVE_PERMISSION_LABEL_KEY = "start_selfserve";
export const PERMISSION_CAPABILITY_LABEL_KEYS = {
VEHICLES_LIST: "view_vehicles",
VEHICLES_EDIT: "edit_vehicles",
VEHICLES_DELETE: "delete_vehicles",
VEHICLES_ADD: "add_vehicles",
SELFSERVE_LIST: "view_selfserve",
SELFSERVE_EDIT: "edit_selfserve",
SELFSERVE_DELETE: "delete_selfserve",
SELFSERVE_ADD: SELF_SERVE_PERMISSION_LABEL_KEY,
BOOKINGS_LIST: "view_bookings",
BOOKINGS_EDIT: "edit_bookings",
BOOKINGS_DELETE: "delete_bookings",
BOOKINGS_ADD: "add_bookings",
ORDERS_LIST: "view_orders",
ORDERS_EDIT: "edit_orders",
SUBUSERS_LIST: "view_drivers",
SUBUSERS_EDIT: "edit_driver_access",
SUBUSERS_DELETE: "disable_driver_access",
SUBUSERS_ADD: "invite_drivers",
};
export const permissionCapabilityLabel = (permissionKey) => {
const normalizedKey = String(permissionKey || "").trim().toUpperCase();
const capabilityKey = PERMISSION_CAPABILITY_LABEL_KEYS[normalizedKey] || normalizedKey;
return i18n.global.t(`superuser.driver_access.capabilities.${capabilityKey}`);
};
export const selfServeAccessLabel = () => permissionCapabilityLabel("SELFSERVE_ADD");
+1 -1
View File
@@ -1463,7 +1463,7 @@ test("customer user with own-subuser permissions can invite and manage grant acc
await expect(page.getByRole("columnheader", { name: "Chauffør" })).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Tlf." })).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Nummerplade" })).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Kundeadgange" })).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Start selvbetjening" })).toBeVisible();
await expect(page.getByTestId("subuser-phone-1")).toContainText("+45 11111111");
await expect(page.getByTestId("subuser-plate-1")).toHaveText("AB12345");
await expect(page.getByTestId("subuser-plate-2")).toHaveText("CD67890");
@@ -0,0 +1,167 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { createI18n } from "vue-i18n";
const translateMock = vi.hoisted(() => {
const translations = {
"common.active": "Active",
"common.inactive": "Inactive",
"common.disabled": "Disabled",
"templates.generated.compat.common.none": "None",
"templates.generated.compat.superuser.driver_contact.missing": "Missing",
"templates.generated.compat.superuser.driver_contact.verified": "Verified",
"templates.generated.compat.superuser.driver_contact.unverified": "Unverified",
"templates.generated.compat.superuser.driver_wheel.account_section": "Account",
"templates.generated.compat.superuser.driver_wheel.contact_section": "Contact",
"templates.generated.compat.superuser.driver_wheel.access_section": "Access",
"templates.generated.compat.superuser.driver_wheel.details_section": "Details",
"templates.generated.compat.superuser.driver_wheel.identity_subsection": "Identity",
"templates.generated.compat.superuser.driver_wheel.system_subsection": "System",
"superuser.driver_access.capabilities.start_selfserve": "Start self-service",
"superuser.driver_access.templates.driver.label": "Driver",
"superuser.driver_access.summary.no_permissions": "No permissions",
"admin.pos.settings_wheel.shortcuts_section": "Shortcuts",
};
return (key, values = undefined) => {
const value = translations[key] || key;
if (!values) {
return value;
}
return Object.entries(values).reduce(
(text, [name, replacement]) => text.replace(`{${name}}`, String(replacement)),
value
);
};
});
vi.mock("@/i18n", () => ({
default: {
global: {
t: translateMock,
},
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
canAccessSuperUser: vi.fn(() => false),
canManageSubusers: vi.fn(() => true),
objects: {
subusers: {
functions: {
permissionSummary: vi.fn(() => "Driver"),
},
},
},
},
}));
const wheelSections = [];
vi.mock("@/components/displays/buttons/ActionSettingsWheelButton.vue", () => ({
default: {
props: ["menuSections"],
setup(props) {
wheelSections.push(props.menuSections);
return () => null;
},
},
}));
import SubusersTable from "@/components/displays/superuser/tables/SubusersTable.vue";
import {
permissionCapabilityLabel,
selfServeAccessLabel,
} from "@/components/session/subuser/subuserPermissionLabels.js";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const mountTable = () =>
mount(SubusersTable, {
props: {
objects: [
{
id: 7,
username: "driver",
name: "Demo Driver",
phone_country_code: 45,
phone: 12345678,
created_at: "2026-07-27 12:00:00",
updated_at: "2026-07-27 12:00:00",
grant_id: 77,
grant_enabled: true,
grant_permissions: ["SELFSERVE_LIST", "SELFSERVE_ADD"],
permission_template_key: "driver",
access_state: "active",
},
],
},
global: {
plugins: [
createI18n({
legacy: false,
locale: "en",
missingWarn: false,
fallbackWarn: false,
messages: { en: {} },
}),
],
stubs: {
"b-switch": {
props: ["modelValue"],
template: '<input type="checkbox" :checked="modelValue" />',
},
"b-tooltip": {
template: "<span><slot /></span>",
},
},
},
});
const flattenItems = (sections) =>
sections.flatMap((section) =>
(section.items || []).flatMap((item) => (item.items ? [item, ...flattenItems([item])] : [item]))
);
describe("subuser management labels", () => {
it("uses one self-service capability label for helpers", () => {
expect(permissionCapabilityLabel("selfserve_add")).toBe("Start self-service");
expect(selfServeAccessLabel()).toBe("Start self-service");
});
it("uses the same self-service label in the table header and settings wheel item", () => {
wheelSections.length = 0;
const wrapper = mountTable();
expect(wrapper.find("thead").text()).toContain("Start self-service");
const wheelItems = flattenItems(wheelSections.at(-1) || []);
expect(wheelItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "subuser-self-serve",
label: "Start self-service",
type: "select",
}),
])
);
});
it("restores the settings-wheel selection when the permission update fails", () => {
const tableSource = readFileSync(
join(process.cwd(), "src/components/displays/superuser/tables/SubusersTable.vue"),
"utf8"
);
const selectSource = readFileSync(
join(process.cwd(), "src/components/displays/buttons/ActionSettingsWheelSelectItem.vue"),
"utf8"
);
expect(tableSource).toContain("if (rejectOnFailure)");
expect(tableSource).toContain("onToggleDognvaskFromSettingsWheel");
expect(selectSource).toContain("localValue.value = normalizeSelectValue(props.value)");
expect(selectSource).toContain("} catch {");
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB