## 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>
288 lines
9.4 KiB
Vue
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>
|