Files
pleno-vue/src/components/displays/selectors/SubuserGrantPermissionNodes.vue
T
Jeppe BandJeppe Bundgaard 7a84cd9162 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>
2026-07-28 17:06:36 +02:00

288 lines
9.4 KiB
Vue

<script setup lang="ts">
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: {
type: Object as () => { id: number; permissions: string[]; name: string; permission_template_key?: string },
default: () => ({ id: 0, permissions: [], name: "", permission_template_key: "custom" }),
required: true,
},
permissions: {
type: Array as () => string[],
default: () => [],
required: true,
},
templateKey: {
type: String,
default: null,
},
allowAdvanced: {
type: Boolean,
default: false,
},
});
type PermissionGroup = {
key: string;
capabilities: string[];
};
type PermissionTemplate = {
key: string;
label: string;
description: string;
enabled: boolean;
permissions: string[];
permission_groups: PermissionGroup[];
};
const t = (key: string, values: Record<string, string> | undefined = undefined) => i18n.global.t(key, values);
const LEGACY_PERMISSION_GROUPS: PermissionGroup[] = [
{
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"],
},
];
const templates = ref<PermissionTemplate[]>([]);
const selectedPermissions = ref<string[]>([]);
const selectedTemplateKey = ref<string>(props.templateKey || props.grant.permission_template_key || "custom");
const isLoading = ref(true);
const emit = defineEmits(["update:permissions", "update:access"]);
const normalizePermissions = (value: string[]) => [...new Set((value || []).filter(Boolean).map((item) => String(item).toUpperCase()))];
const samePermissions = (left: string[], right: string[]) => {
const a = normalizePermissions(left).sort();
const b = normalizePermissions(right).sort();
return a.length === b.length && a.every((value, index) => value === b[index]);
};
const findTemplate = (key: string | null | undefined) => templates.value.find((template) => template.key === key) || null;
const classifyTemplate = (permissions: string[]) => {
if (permissions.length === 0) {
return "deactivated";
}
const match = templates.value.find((template) => template.key !== "deactivated" && samePermissions(template.permissions, permissions));
return match?.key || "custom";
};
const emitAccess = () => {
emit("update:permissions", [...selectedPermissions.value]);
emit("update:access", {
permission_template_key: selectedTemplateKey.value,
permissions: [...selectedPermissions.value],
});
};
const syncPermissions = (value: string[]) => {
selectedPermissions.value = normalizePermissions(value);
if (templates.value.length > 0 && (!props.templateKey || props.templateKey === "custom")) {
selectedTemplateKey.value = classifyTemplate(selectedPermissions.value);
}
};
const selectTemplate = (key: string) => {
if (key === "custom") {
selectedTemplateKey.value = "custom";
emitAccess();
return;
}
const template = findTemplate(key);
if (!template) {
return;
}
selectedTemplateKey.value = template.key;
selectedPermissions.value = normalizePermissions(template.permissions);
emitAccess();
};
const togglePermission = (permissionKey: string, enabled: boolean) => {
selectedTemplateKey.value = "custom";
if (enabled) {
selectedPermissions.value = [...new Set([...selectedPermissions.value, permissionKey])];
} else {
selectedPermissions.value = selectedPermissions.value.filter((value) => value !== permissionKey);
}
emitAccess();
};
const visibleTemplates = computed(() => templates.value.filter((template) => template.key !== "custom"));
const activeTemplate = computed(() => findTemplate(selectedTemplateKey.value));
const showAdvanced = computed(() => props.allowAdvanced && selectedTemplateKey.value === "custom");
const templateLabel = (key: string, _fallback = "") => t(`superuser.driver_access.templates.${key}.label`);
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) => permissionCapabilityLabel(permission);
watch(
() => props.permissions,
(newPermissions) => {
syncPermissions(newPermissions || []);
emitAccess();
},
{ immediate: true }
);
onMounted(async () => {
try {
const accessModel = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
templates.value = Array.isArray(accessModel?.templates) ? accessModel.templates : [];
selectedTemplateKey.value = props.templateKey || props.grant.permission_template_key || classifyTemplate(selectedPermissions.value);
if (selectedTemplateKey.value !== "custom") {
const template = findTemplate(selectedTemplateKey.value);
if (template) {
selectedPermissions.value = normalizePermissions(template.permissions);
}
}
emitAccess();
} catch (error) {
console.error("Failed to fetch permission templates:", error);
templates.value = [];
selectedTemplateKey.value = "custom";
} finally {
isLoading.value = false;
}
});
</script>
<template>
<div>
<h2 class="title is-4">{{ t("superuser.driver_access.editor_title", { name: props.grant.name }) }}</h2>
<p class="mb-4 has-text-grey">
{{ t("superuser.driver_access.editor_intro") }}
</p>
<div v-if="isLoading" class="notification is-light">
{{ t("superuser.driver_access.loading") }}
</div>
<div v-else-if="visibleTemplates.length === 0" class="notification is-warning is-light">
{{ t("superuser.driver_access.empty") }}
</div>
<div v-else class="driver-access-editor">
<button
v-for="template in visibleTemplates"
:key="template.key"
type="button"
class="driver-access-card"
:class="{ 'driver-access-card--selected': selectedTemplateKey === template.key }"
:data-testid="`permission-template-${template.key}`"
@click="selectTemplate(template.key)"
>
<span class="driver-access-card__title">{{ templateLabel(template.key, template.label) }}</span>
<span class="driver-access-card__description">{{ templateDescription(template) }}</span>
<span v-if="selectedTemplateKey === template.key" class="tag is-success is-light">{{ t("superuser.driver_access.selected") }}</span>
</button>
<button
v-if="allowAdvanced"
type="button"
class="driver-access-card"
:class="{ 'driver-access-card--selected': selectedTemplateKey === 'custom' }"
data-testid="permission-template-custom"
@click="selectTemplate('custom')"
>
<span class="driver-access-card__title">{{ templateLabel("custom") }}</span>
<span class="driver-access-card__description">{{ t("superuser.driver_access.custom_description") }}</span>
<span v-if="selectedTemplateKey === 'custom'" class="tag is-warning is-light">{{ t("superuser.driver_access.advanced") }}</span>
</button>
</div>
<div v-if="activeTemplate && selectedTemplateKey !== 'custom'" class="notification is-info is-light mt-4">
<strong>{{ templateLabel(activeTemplate.key, activeTemplate.label) }}</strong>
<p>{{ templateDescription(activeTemplate) }}</p>
</div>
<div v-if="showAdvanced" class="advanced-access mt-5" data-testid="permission-template-custom-editor">
<div v-for="group in LEGACY_PERMISSION_GROUPS" :key="group.key" class="box">
<h3 class="title is-6">{{ groupLabel(group.key) }}</h3>
<div class="columns is-multiline">
<div v-for="capability in group.capabilities" :key="capability" class="column is-6">
<b-checkbox
:model-value="selectedPermissions.includes(capability)"
:data-testid="`permission-node-checkbox-${capability}`"
@update:model-value="(checked) => togglePermission(capability, !!checked)"
@input="(checked) => togglePermission(capability, !!checked)"
>
{{ permissionLabel(capability) }}
</b-checkbox>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.driver-access-editor {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.driver-access-card {
align-items: flex-start;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.4rem;
min-height: 150px;
padding: 1rem;
text-align: left;
}
.driver-access-card:hover,
.driver-access-card--selected {
border-color: #3273dc;
box-shadow: 0 0 0 1px #3273dc;
}
.driver-access-card__title {
color: #1f2933;
font-weight: 700;
}
.driver-access-card__description {
color: #4a4a4a;
font-size: 0.9rem;
line-height: 1.35;
}
.advanced-access :deep(.checkbox) {
line-height: 1.35;
}
</style>