## Summary - add chauffeur/subuser password recovery by SMS and authenticated password changes - add a read-only pre-authorized access-request preview with explicit approve/deny actions - replace duplicate customer grants with one deduplicated Buefy dropdown - show colored vehicle, toolbox, calendar, order, and driver permission indicators - add localized copy across all supported locale sources and generated catalogs ## Verification - ESLint passed - grant deduplication/icon unit tests: 2 passed - authentication Playwright coverage: 3 passed - authorized subuser management Playwright coverage: 1 passed - mocked direct approval browser flow passed - i18n source/runtime checks passed - production build passed (2,034 modules transformed) - `git diff --check` passed ## Paired delivery Paired API PR: https://github.com/copenhagentruckwash/api/pull/325 ## Visual change previews ### View: Forgot-password account selection **Description:** Visitors can now choose customer or chauffeur recovery; chauffeur recovery requests the country code and phone number used for the SMS reset link. #### Mobile (390 x 844) **Before:**  **After:**  #### Tablet (768 x 1024) **Before:**  **After:**  #### Desktop (1440 x 900) **Before:**  **After:**  ### View: Pre-authorized customer access decision **Description:** The SMS destination now previews the exact chauffeur and customer request and requires an explicit approve or deny action before mutating access. #### Mobile (390 x 844) **Before:**  **After:**  #### Tablet (768 x 1024) **Before:**  **After:**  #### Desktop (1440 x 900) **Before:**  **After:**  --------- Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
263 lines
6.8 KiB
Vue
263 lines
6.8 KiB
Vue
<script setup>
|
|
import { computed, watch } from "vue";
|
|
import i18n from "@/i18n";
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import {
|
|
normalizeSubuserGrantOptions,
|
|
permissionGroupsForGrant,
|
|
} from "@/components/session/subuser/subuserGrantOptions.js";
|
|
|
|
defineProps({
|
|
showLabel: { type: Boolean, default: true },
|
|
compact: { type: Boolean, default: false },
|
|
});
|
|
|
|
const t = (key, values = undefined) => i18n.global.t(key, values);
|
|
const selectedCustomerNumber = computed(() => SessionUser.subuser.selectedGrantCustomerNumber.value);
|
|
const isSubuser = computed(() => SessionUser.isSubuser.value);
|
|
|
|
const grants = computed(() => normalizeSubuserGrantOptions(SessionUser.subuser.grants.value));
|
|
|
|
watch(
|
|
grants,
|
|
(newGrants) => {
|
|
const selectedStillExists = newGrants.some(
|
|
(grant) => grant.billing_customer_number === selectedCustomerNumber.value
|
|
);
|
|
if (!selectedStillExists) {
|
|
SessionUser.subuser.selectGrant(newGrants.length === 1 ? newGrants[0].billing_customer_number : null);
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
const selectGrant = (customerNumber) => SessionUser.subuser.selectGrant(customerNumber);
|
|
const selectedGrant = computed(
|
|
() => grants.value.find((grant) => grant.billing_customer_number === selectedCustomerNumber.value) || null
|
|
);
|
|
const customerLabel = (grant) =>
|
|
grant?.name || `${t("common.customer")} #${grant?.billing_customer_number || "-"}`;
|
|
const selectedGrantName = computed(() =>
|
|
selectedGrant.value
|
|
? customerLabel(selectedGrant.value)
|
|
: t("user_home.select_customer")
|
|
);
|
|
|
|
const visiblePermissionGroups = permissionGroupsForGrant;
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="isSubuser && grants.length === 0"
|
|
class="grant-selector no-permissions"
|
|
:class="{ 'is-compact': compact }"
|
|
data-testid="subuser-grant-empty"
|
|
>
|
|
<b-message type="is-warning" size="is-small" has-icon>
|
|
{{ t("superuser.driver_access.summary.no_permissions") }}
|
|
</b-message>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="isSubuser && grants.length > 0"
|
|
class="grant-selector"
|
|
:class="{ 'is-compact': compact }"
|
|
data-testid="subuser-grant-selector"
|
|
>
|
|
<label v-if="showLabel" class="label">
|
|
{{ t("user_home.select_customer") }}
|
|
</label>
|
|
|
|
<b-dropdown
|
|
position="is-bottom-left"
|
|
append-to-body
|
|
aria-role="list"
|
|
:mobile-modal="false"
|
|
class="grant-selector__dropdown"
|
|
>
|
|
<template #trigger>
|
|
<b-button
|
|
class="grant-selector__trigger"
|
|
:size="compact ? 'is-small' : undefined"
|
|
icon-left="building"
|
|
icon-right="chevron-down"
|
|
expanded
|
|
data-testid="subuser-grant-trigger"
|
|
>
|
|
<span class="grant-selector__trigger-label">{{ selectedGrantName }}</span>
|
|
<span v-if="selectedGrant" class="grant-selector__trigger-icons" aria-hidden="true">
|
|
<i
|
|
v-for="group in visiblePermissionGroups(selectedGrant)"
|
|
:key="group.key"
|
|
:class="[group.icon, group.color]"
|
|
></i>
|
|
</span>
|
|
</b-button>
|
|
</template>
|
|
|
|
<b-dropdown-item
|
|
v-for="grant in grants"
|
|
:key="grant.billing_customer_number"
|
|
aria-role="listitem"
|
|
custom
|
|
focusable
|
|
:data-testid="`subuser-grant-option-${grant.billing_customer_number}`"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="grant-selector__option"
|
|
:class="{ 'is-active': grant.billing_customer_number === selectedCustomerNumber }"
|
|
@click="selectGrant(grant.billing_customer_number)"
|
|
>
|
|
<span class="grant-selector__option-check" aria-hidden="true">
|
|
<i
|
|
:class="
|
|
grant.billing_customer_number === selectedCustomerNumber
|
|
? 'fas fa-check-circle has-text-success'
|
|
: 'far fa-circle has-text-grey-light'
|
|
"
|
|
></i>
|
|
</span>
|
|
<span class="grant-selector__option-copy">
|
|
<strong>{{ customerLabel(grant) }}</strong>
|
|
<small>#{{ grant.billing_customer_number }}</small>
|
|
</span>
|
|
<span class="grant-selector__permissions">
|
|
<b-tooltip
|
|
v-for="group in visiblePermissionGroups(grant)"
|
|
:key="group.key"
|
|
:label="t(`superuser.driver_access.groups.${group.key}`)"
|
|
position="is-top"
|
|
>
|
|
<span class="grant-selector__permission-icon" :class="group.color">
|
|
<i :class="group.icon"></i>
|
|
</span>
|
|
</b-tooltip>
|
|
<span
|
|
v-if="visiblePermissionGroups(grant).length === 0"
|
|
class="grant-selector__permission-icon has-text-grey-light"
|
|
:title="t('superuser.driver_access.summary.no_permissions')"
|
|
>
|
|
<i class="fas fa-lock"></i>
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</b-dropdown-item>
|
|
</b-dropdown>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.grant-selector {
|
|
margin-bottom: 1rem;
|
|
min-width: min(100%, 19rem);
|
|
}
|
|
|
|
.grant-selector.is-compact {
|
|
margin-bottom: 0;
|
|
min-width: 0;
|
|
}
|
|
|
|
.grant-selector__dropdown,
|
|
.grant-selector__trigger {
|
|
width: 100%;
|
|
}
|
|
|
|
.grant-selector__trigger {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.grant-selector__trigger-label {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.grant-selector__trigger-icons {
|
|
display: inline-flex;
|
|
gap: 0.35rem;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.grant-selector__option {
|
|
align-items: center;
|
|
background: transparent;
|
|
border: 0;
|
|
border-radius: 0.45rem;
|
|
color: #25344d;
|
|
cursor: pointer;
|
|
display: grid;
|
|
gap: 0.65rem;
|
|
grid-template-columns: 1.25rem minmax(8rem, 1fr) auto;
|
|
padding: 0.7rem 0.8rem;
|
|
text-align: left;
|
|
width: min(26rem, calc(100vw - 2rem));
|
|
}
|
|
|
|
.grant-selector__option:hover,
|
|
.grant-selector__option:focus-visible {
|
|
background: #f2f6fb;
|
|
outline: none;
|
|
}
|
|
|
|
.grant-selector__option.is-active {
|
|
background: #edf8f1;
|
|
}
|
|
|
|
.grant-selector__option-copy {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.grant-selector__option-copy strong {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.grant-selector__option-copy small {
|
|
color: #6b7280;
|
|
}
|
|
|
|
.grant-selector__permissions {
|
|
align-items: center;
|
|
display: inline-flex;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.grant-selector__permission-icon {
|
|
align-items: center;
|
|
background: currentColor;
|
|
border-radius: 999px;
|
|
display: inline-flex;
|
|
height: 1.65rem;
|
|
justify-content: center;
|
|
width: 1.65rem;
|
|
}
|
|
|
|
.grant-selector__permission-icon i {
|
|
color: white;
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.no-permissions :deep(.message) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.grant-selector__trigger-icons {
|
|
display: none;
|
|
}
|
|
|
|
.grant-selector__option {
|
|
grid-template-columns: 1.25rem minmax(6rem, 1fr);
|
|
}
|
|
|
|
.grant-selector__permissions {
|
|
grid-column: 2;
|
|
}
|
|
}
|
|
</style>
|