Add permission checks and refine UI elements

Introduce `RequiresPermission` component to enforce role-based access control throughout the application. Refine UI elements with sharper input styles and ensure focused accessibility improvements (e.g., auto-focus on specific fields). Enhance vehicle data parsing and popper usability, while maintaining robust handling of asynchronous operations.
This commit is contained in:
Jepp9350
2025-02-25 13:10:11 +01:00
parent edc979d8fb
commit 333898469d
13 changed files with 332 additions and 96 deletions
+15
View File
@@ -87,6 +87,19 @@ export const ButtonColors = {
},
};
export const InputColors = {
default: {
/** The background color of the input */
backgroundColor: "#f9f9f9",
/** The text color of the input */
textColor: GlobalColors.textColor,
/** The hover color of the input */
hoverColor: GlobalColors.hoverColor,
/** The active color of the input */
activeColor: GlobalColors.activeColor,
},
};
export const Colors = {
/** The colors of the application */
global: GlobalColors,
@@ -96,5 +109,7 @@ export const Colors = {
headers: HeaderColors,
/** The colors of the buttons */
buttons: ButtonColors,
/** The colors of the inputs */
inputs: InputColors,
};
</script>
+6 -1
View File
@@ -1 +1,6 @@
@import './base.css';
.input.has-sharp-edges {
border-radius: 0;
}
textarea.has-sharp-edges {
border-radius: 0;
}
+6 -2
View File
@@ -51,8 +51,12 @@ export const removePopperIfOpen = () => {
}
};
export const popperBox = (title, content) => {
return `<div class="">
export const popperBox = (title, content, data_key = null) => {
// If the data key is not set, we want to use a random key
if (!data_key) {
data_key = Math.random().toString(36).substring(7);
}
return `<div class="popper-box" data-key="${data_key}">
<article class="message">
<div class="message-body">
<strong>${title}</strong><br>
@@ -19,6 +19,7 @@ import {
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {ref, watch, onMounted} from 'vue';
import {popperBox, showPopper, showPopperWithContent, removePopperIfOpen} from "@/components/displays/PopperDefault.vue";
import { Colors } from "@/ThemeConfig.vue";
const activeTab = ref(null);
@@ -161,7 +162,7 @@ const generatePopperPlate = (scan, motorapi_data) => {
use: decodeMotorAPIString(motorapi_data.use)
}
return `
<div>
<div class="numberplate-popper" data-plate="${scan.plate}">
<p><strong>Mærke:</strong> ${data.make}</p>
<p><strong>Model:</strong> ${data.model}</p>
<p><strong>Variant:</strong> ${data.variant}</p>
@@ -173,6 +174,14 @@ const generatePopperPlate = (scan, motorapi_data) => {
const isPlatePopperLoading = ref(false);
const shownNumberPlatePoppers = ref([]);
const removePlatePoppersIfOpen = () => {
shownNumberPlatePoppers.value.forEach((popperId) => {
removePopperIfOpen(popperId);
});
};
const showPopperPlate = (scan, element) => {
// If the plate is loading, then return
@@ -188,10 +197,13 @@ const showPopperPlate = (scan, element) => {
SessionUser.superUser.modules.motorapi.functions.lookup(scan.plate).then((response) => {
const motorapi_data = response.data.data;
let content = generatePopperPlate(scan, motorapi_data);
const uniqueId = "plate-popper-" + scan.plate;
showPopper(popperBox(
'Nummerplade: ' + scan.plate,
content
content,
uniqueId
), element);
shownNumberPlatePoppers.value.push(uniqueId);
isPlatePopperLoading.value = false;
}).catch((error) => {
isPlatePopperLoading.value = false;
@@ -225,26 +237,41 @@ const getPlateScannerName = (id) => {
</script>
<template>
<div>
<article class="panel is-dark" id="scans-panel">
<p class="panel-heading">Nummerplader</p>
<p class="panel-tabs">
<a v-for="(tab, index) in panel_tabs" :key="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true ; changeTab(index)">
{{ tab.name }}
</a>
<div @mouseleave="removePlatePoppersIfOpen()">
<article class="" id="scans-panel">
<p
class="is-size-4 title"
style="font-weight: 400;"
>
Nummerplader
</p>
<p
class="is-size-6 subtitle mb-1"
>
Seneste scanninger
</p>
<p class="control mb-2">
<input
@input="search($event.target.value)"
class="input mt-2 has-sharp-edges has-placeholder-italic"
type="text"
placeholder="Søg efter nummerplade"
style="border-color: transparent;"
:style="{
'background-color': Colors.inputs.default.backgroundColor,
'color': Colors.inputs.default.textColor
}"
v-model="metaSearch"
/>
</p>
<div class="panel-block" v-if="panel_tabs[1].active">
<p class="control">
<input @input="search($event.target.value)" class="input mt-2" type="text" placeholder="Søg efter nummerplade" v-model="metaSearch" />
</p>
</div>
<div id="scans-list" class="scans-list">
<a class="panel-block plate-scan-entry" v-for="scan in scansInfinityScroll" :key="scan.id" @click="selectScan(scan)"
@mouseenter="showPopperPlate(scan, $event.target)"
@mouseleave="removePopperIfOpen()">
<span class="panel-icon">
@mouseleave="removePlatePoppersIfOpen()">
<!--<span class="panel-icon">
<i class="fas fa-car" aria-hidden="true"></i>
</span>
</span> -->
<span style="width: 50%;">{{ scan.plate }}</span>
<span class="has-text-centered"
v-if="plate_scanners.length > 0"
@@ -290,4 +317,10 @@ const getPlateScannerName = (id) => {
.scans-list::-webkit-scrollbar-track {
background-color: transparent;
}
.input.has-sharp-edges {
border-radius: 0;
}
.input.has-placeholder-italic::placeholder {
font-style: italic;
}
</style>
@@ -1,32 +1,46 @@
<script setup>
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
defineProps(['notes', 'isAddFormVisible', 'isOldNotesVisible']);
import { ref } from 'vue';
import { addCustomerNote, isCustomerSelected, showDeleteNoteDialog } from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const newNote = ref('');
</script>
<template>
<div>
<RequiresPermission permission="list_customer_notes">
<div class="field">
<label class="label" v-if="notes.length > 0 || isAddFormVisible">Kundebemærkninger</label>
<div class="control mt-3" v-if="isOldNotesVisible">
<div class="message" v-for="note in notes" :key="note.id">
<div class="message-header">
<p>{{ note.created_at }}</p>
<button class="delete" @click="showDeleteNoteDialog(note.id)"></button>
</div>
<div class="message-body">
{{ note.note }}
<RequiresPermission permission="delete_customer_note">
<button class="delete" @click="showDeleteNoteDialog(note.id)"></button>
</RequiresPermission>
</div>
<div class="message-body">
{{ note.note }}
</div>
</div>
</div>
<div class="control mt-3" v-if="isAddFormVisible">
<textarea class="textarea" v-model="newNote" />
<button class="button is-dark is-fullwidth mt-3" @click="addCustomerNote(newNote)" v-if="isCustomerSelected()">Tilføj bemærkning</button>
<button class="button is-dark is-fullwidth mt-3" @click="addCustomerNote" v-else disabled>Tilføj bemærkning</button>
</div>
<RequiresPermission permission="add_customer_note">
<div class="control mt-3" v-if="isAddFormVisible">
<textarea class="textarea has-sharp-edges" v-model="newNote" />
<button
class="button is-dark is-fullwidth mt-3"
@click="addCustomerNote(newNote)"
v-if="isCustomerSelected() && newNote.length > 0"
>
Tilføj bemærkning
</button>
</div>
</RequiresPermission>
</div>
</RequiresPermission>
</div>
</template>
@@ -5,19 +5,22 @@ import {ref, watch} from 'vue';
import 'bulma-switch/dist/css/bulma-switch.min.css';
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {popperBox, popper, removePopperIfOpen, showPopperWithContent, showPopper} from "@/components/displays/PopperDefault.vue";
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
const panel_tabs = ref([
{
name: 'Detaljer',
active: true
active: true,
permission: 'list_customer_details'
},
{
name: 'Regler',
active: false
active: false,
permission: 'list_customer_attributes'
},
{
name: 'Genveje',
active: false
active: false,
}
]);
@@ -139,13 +142,30 @@ loadCustomerAttributes();
<template>
<div v-if="customer_name">
<article class="panel is-dark">
<p class="panel-heading">{{ customer_name }}</p>
<p class="panel-tabs">
<a v-for="(tab, index) in panel_tabs" :key="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true">
{{ tab.name }}
</a>
</p>
<article class="">
<div class="panel-block">
<div class="columns is-vcentered">
<div class="column">
<span class="is-size-6 title">{{ customer_name }}</span>
</div>
<div class="column is-narrow">
<div class="tabs is-right">
<ul>
<li v-for="(tab, index) in panel_tabs" :key="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true">
<RequiresPermission :permission="tab.permission" v-if="tab.permission">
<a>
{{ tab.name }}
</a>
</RequiresPermission>
<a v-else>
{{ tab.name }}
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<a class="panel-block" v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
<span class="panel-icon">
<i :class="detail.icon" aria-hidden="true"></i>
@@ -153,49 +173,53 @@ loadCustomerAttributes();
<span style="width: 50%;">{{ detail.name }}</span><span class="has-text-right" style="width: 50%;">{{ customer_data[detail.prop] }}</span>
</a>
<!-- Attributes -->
<div class="panel-block" v-if="panel_tabs[1].active" v-for="attribute in attributes" :key="attribute.name">
<span class="panel-icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span style="width: 50%;"
@mouseover="showPopper(
popperBox(
attribute.name,
attribute.description
),
$event.target
)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span>
<span class="has-text-right" style="width: 50%;">
<span v-if="hasAttribute(attribute.prop)" @click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input :id="attribute.prop"
type="checkbox" :name="attribute.prop"
class="switch" checked="checked"
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
/>
<label :for="attribute.prop"></label>
<RequiresPermission permission="list_customer_attributes">
<div class="panel-block" v-if="panel_tabs[1].active" v-for="attribute in attributes" :key="attribute.name">
<span class="panel-icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span style="width: 50%;"
@mouseover="showPopper(
popperBox(
attribute.name,
attribute.description
),
$event.target
)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span>
<span class="has-text-right" style="width: 50%;">
<span v-if="hasAttribute(attribute.prop)" @click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input :id="attribute.prop"
type="checkbox" :name="attribute.prop"
class="switch" checked="checked"
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input :id="attribute.prop"
type="checkbox" :name="attribute.prop"
class="switch" :disabled="!SessionUser.hasPermission('add_customer_attribute')"/>
<label :for="attribute.prop"></label>
</span>
</span>
</span>
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input :id="attribute.prop"
type="checkbox" :name="attribute.prop"
class="switch" :disabled="!SessionUser.hasPermission('add_customer_attribute')"/>
<label :for="attribute.prop"></label>
</span>
</span>
</span>
</div>
</div>
</RequiresPermission>
<!-- Shortcuts -->
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
<a class="panel-block" v-if="shortcut.visible">
<span class="panel-icon">
<i :class="shortcut.icon" aria-hidden="true"></i>
</span>
<span @click="shortcut.action">{{ shortcut.name }}</span>
</a>
<RequiresPermission permission="access_super_user">
<a class="panel-block">
<span class="panel-icon">
<i :class="shortcut.icon" aria-hidden="true"></i>
</span>
<span @click="shortcut.action">{{ shortcut.name }}</span>
</a>
</RequiresPermission>
</template>
</article>
<div>
@@ -11,9 +11,15 @@ import { clearCache } from "@/components/shop/POSDepartmentProcess.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
import { isCustomerSelected } from "@/components/shop/POSDepartmentProcess.vue";
import {onMounted} from "vue";
// Clear cache
clearCache();
// Select the "pos_select_customer_input" input field, and focus on it on page load
onMounted(() => {
document.getElementById("pos_select_customer_input").focus();
});
</script>
<template>
@@ -0,0 +1,44 @@
<script setup>
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {useSlots, defineProps} from 'vue';
import {IS_DEV} from "@/main.js";
const props = defineProps(['permission']);
const slots = useSlots();
const isSlotEmpty = () => {
return Object.keys(slots).length === 0;
}
// Show an error if the permission prop is not provided
if (!props.permission) {
console.error('The permission prop is required for the requiresPermission component');
}
// Check if the user has access to the permission
const checkAccess = () => {
// If the global debug flag is set to true, show the missing permission warning in the console
if (IS_DEV) {
if (!SessionUser.hasPermission(props.permission)) {
console.warn(`The user does not have the permission ${props.permission}`);
}
else {
console.log(`The user has the permission ${props.permission}`);
}
}
return SessionUser.hasPermission(props.permission);
}
</script>
<template>
<div v-if="checkAccess()">
<slot name="default"></slot>
</div>
<div v-else-if="slots.fail">
<slot name="fail"></slot>
</div>
</template>
<style scoped>
</style>
@@ -68,7 +68,18 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
<div class="field" :class="{'is-hidden': isCustomerSelected()}">
<label class="label">Customer Search (ID / Name)</label>
<div class="control" :class="{'is-loading': isSearching}">
<CustomerSearchField :v-model="customer_id" class="input" type="text" :disabled="isCustomerSelected()" @keydown="arrowKeyHandler" @focusout="selectedDropdownItem = -1; lostfocus()" @focusin="showSelector = true" :tabindex="isCustomerSelected() ? -1 : 0" />
<CustomerSearchField
:v-model="customer_id"
class="input has-sharp-edges"
type="text"
:disabled="isCustomerSelected()"
@keydown="arrowKeyHandler"
@focusout="selectedDropdownItem = -1; lostfocus()"
@focusin="showSelector = true"
:tabindex="isCustomerSelected() ? -1 : 0"
autocomplete="off"
id="pos_select_customer_input"
/>
</div>
<div class="dropdown" :class="{'is-active': showSelector && searchCustomerResults.length > 0}">
<div class="dropdown-menu">
@@ -86,7 +97,7 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
</div>
<div class="field has-addons-right has-addons mt-1" :class="{'is-hidden': !isCustomerSelected()}">
<p class="control is-expanded">
<input class="input" type="text" v-model="customer_name" disabled />
<input class="input has-sharp-edges" type="text" v-model="customer_name" disabled />
</p>
<p class="control">
<button class="button is-danger" @click="selectCustomer(null)" :tabindex="isCustomerSelected() ? 1 : -1">
@@ -98,12 +109,27 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
<div class="field">
<label class="label">Reference nr.</label>
<div class="control">
<input class="input" type="text" v-model="reference" id="reference" tabindex="2" @keydown="keyDownNextInput($event, 'reg_1')" />
<input
class="input has-sharp-edges"
type="text"
v-model="reference"
id="reference"
tabindex="2"
@keydown="keyDownNextInput($event, 'reg_1')"
autocomplete="off"
/>
</div>
</div>
<div class="field">
<label class="label">Reg 1</label>
<LicensePlateInput :tab-index="3" @keydown="keyDownNextInput($event, 'reg_2')" input-id="reg_1" v-model:inputModel="reg_1" :customer_number="parseInt(customer_id) || 0" />
<LicensePlateInput
:tab-index="3"
@keydown="keyDownNextInput($event, 'reg_2')"
input-id="reg_1"
v-model:inputModel="reg_1"
:customer_number="parseInt(customer_id) || 0"
class="has-sharp-edges"
/>
</div>
<div class="field">
<label class="label">Reg 2</label>
@@ -117,8 +143,11 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
<LicensePlateInput :tab-index="5" @keydown="keyDownNextTabIndexButton($event, 6)" input-id="reg_3" v-model:inputModel="reg_3" :customer_number="parseInt(customer_id) || 0" />
</div>
</div>
<PosNotes :notes="notes" :is-loading="false" :isAddFormVisible="SessionUser.adminUser" :isOldNotesVisible="false" class="mt-3"/>
<PosNotes :notes="notes" :is-loading="false" :isAddFormVisible="SessionUser.adminUser" :isOldNotesVisible="false" class="mt-3" v-if="isCustomerSelected()" />
</template>
<style scoped>
.input.has-sharp-edges {
border-radius: 0;
}
</style>
@@ -210,7 +210,16 @@ const isDropdownItemSelected = (registrationNumber) => {
<div
class="control has-icons-right"
:class="{'is-loading': isLoading}">
<input class="input reg-input" type="text" @focusout="runParserFocusOut" @input="runParserInput" :class="{'is-danger': !isValueValid() && !isEmpty()}" :id="props.inputId" :tabindex="props.tabIndex" autocomplete="off" @keydown="pressedKey($event)" />
<input class="input reg-input has-sharp-edges"
type="text"
@focusout="runParserFocusOut"
@input="runParserInput"
:class="{'is-danger': !isValueValid() && !isEmpty()}"
:id="props.inputId"
:tabindex="props.tabIndex"
autocomplete="off"
@keydown="pressedKey($event)"
/>
<span class="icon is-small is-right" v-if="isValueValid()">
<i class="fas fa-check"></i>
</span>
@@ -225,17 +234,20 @@ const isDropdownItemSelected = (registrationNumber) => {
{{ getError('license_plate_input_' + uniqueFieldId) }}
</p>
<!-- Vehicle data -->
<div class="vehicle-data" v-if="isValueValid() && !isEmpty() && !isLoading && vehicleData.registration_number">
<p class="vehicle-data-item">Mærke: <strong>{{ vehicleData.make }}</strong></p>
<p class="vehicle-data-item">Model: <strong>{{ vehicleData.model }}</strong></p>
<p class="vehicle-data-item">Variant: <strong>{{ vehicleData.variant }}</strong></p>
<p class="vehicle-data-item">Type: <strong>{{ vehicleData.type }}</strong></p>
<!-- Pretty print the vehicle data -->
<!--<pre>{{ vehicleData }}</pre> -->
</div>
<p class="help is-info" v-if="isLoading">
Henter køretøjsdata...
</p>
<p class="help is-info" v-if="isValueValid() && !isEmpty() && !isLoading && vehicleData.registration_number">
<!-- Plate details -->
{{ vehicleData.make }} - {{ vehicleData.model }} - {{ vehicleData.variant }} - {{ vehicleData.type }} - {{ SessionUser.adminUser.plates.parse_motorapi_string(vehicleData.use) }}
</p>
</div>
<!-- Search results -->
<div class="dropdown" :class="{'is-active': customerRegistrationNumbers.length > 0 && !isLoading && !isEmpty() && isUserFocused()}">
<div
class="dropdown"
:class="{'is-active': customerRegistrationNumbers.length > 0 && !isLoading && !isEmpty() && isUserFocused()}"
v-if="customerRegistrationNumbers.length > 0 && !isLoading && !isEmpty() && isUserFocused()"
>
<div class="dropdown-menu">
<div class="dropdown-content">
<a class="dropdown-item" v-for="registrationNumber in customerRegistrationNumbers" :key="registrationNumber.registration_number" @click="setInputValue(registrationNumber.registration_number)" :class="{'is-active': isDropdownItemSelected(registrationNumber)}">
@@ -248,5 +260,7 @@ const isDropdownItemSelected = (registrationNumber) => {
</template>
<style scoped>
.input.has-sharp-edges {
border-radius: 0;
}
</style>
@@ -26,5 +26,22 @@ export const plates = {
console.error(error);
});
},
/**
* Parse the motorapi string
* This is used to decode the Unicode characters like u00f8 to ø
* @param string The string to parse
* @returns {*}
*/
parse_motorapi_string: (string) => {
{
// Check if the string contains u00, if so add a backslash
if (string.includes('u00')) {
string = string.replace(/u00/g, '\\u00');
}
// unicode decode
return string.replace(/\\u[\dA-F]{4}/gi,
(match) => String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16)));
}
}
}
</script>
+32 -2
View File
@@ -19,11 +19,19 @@ export const getAttributes = (customer_number) => {
});
};
const isAttributesLoading = ref(false);
export const createAttribute = (customer_number, attribute) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
}
// If the loading state is true, return null
if (isAttributesLoading.value) {
return null;
}
// Set the loading state to true
isAttributesLoading.value = true;
return axios.post(API_URL + '/customer/attributes', {
customer_number,
attribute
@@ -31,7 +39,15 @@ export const createAttribute = (customer_number, attribute) => {
headers: {
Authorization: `Bearer ${token}`
}
});
}).then(
() => {
isAttributesLoading.value = false;
}
).catch(
() => {
isAttributesLoading.value = false;
}
);
};
export const deleteAttribute = (customer_number, attribute) => {
@@ -39,10 +55,24 @@ export const deleteAttribute = (customer_number, attribute) => {
if (!token) {
return null;
}
// If the loading state is true, return null
if (isAttributesLoading.value) {
return null;
}
// Set the loading state to true
isAttributesLoading.value = true;
return axios.delete(API_URL + '/customer/attributes?customer_number=' + customer_number + '&attribute=' + attribute, {
headers: {
Authorization: `Bearer ${token}`
}
});
}).then(
() => {
isAttributesLoading.value = false;
}
).catch(
() => {
isAttributesLoading.value = false;
}
);
};
</script>
+1
View File
@@ -10,6 +10,7 @@ import App from './App.vue'
import store from './store/user.vue'
import {router} from "@/router.js";
import {Colors} from "./ThemeConfig.vue";
import '@/assets/main.css';
import applyMiddleware from '@/middleware/index.js';
applyMiddleware(router);