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:**  **After:**  #### Tablet (768x1024) **Before:**  **After:**  #### Desktop (1440x900) **Before:**  **After:**  --------- Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
This commit is contained in:
co-authored by
Jeppe Bundgaard
parent
4f26ddd2cc
commit
7a84cd9162
@@ -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");
|
||||
@@ -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 |
Reference in New Issue
Block a user