Files
pleno-vue/src/components/session/subuser/SubuserGrantSelector.vue
T
Jeppe BandJeppe Bundgaard b7859d4ede Add subuser access and recovery flows (#231)
## 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:**
![Password recovery mobile
before](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/before-mobile.png)

**After:**
![Password recovery mobile
after](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/after-mobile.png)

#### Tablet (768 x 1024)

**Before:**
![Password recovery tablet
before](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/before-tablet.png)

**After:**
![Password recovery tablet
after](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/after-tablet.png)

#### Desktop (1440 x 900)

**Before:**
![Password recovery desktop
before](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/before-desktop.png)

**After:**
![Password recovery desktop
after](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/after-desktop.png)

### 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:**
![Access decision mobile
before](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/access-before-mobile.png)

**After:**
![Access decision mobile
after](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/access-after-mobile.png)

#### Tablet (768 x 1024)

**Before:**
![Access decision tablet
before](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/access-before-tablet.png)

**After:**
![Access decision tablet
after](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/access-after-tablet.png)

#### Desktop (1440 x 900)

**Before:**
![Access decision desktop
before](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/access-before-desktop.png)

**After:**
![Access decision desktop
after](https://raw.githubusercontent.com/copenhagentruckwash/pleno-vue/6c515817b934d838ae9d5e0192157c87e1e0a8d5/docs/pr-previews/customer-subuser-notifier/access-after-desktop.png)

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-27 18:40:43 +02:00

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>