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>
This commit is contained in:
Jeppe B
2026-07-27 18:40:43 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent cbdca71e3f
commit b7859d4ede
41 changed files with 784 additions and 205 deletions
@@ -0,0 +1,26 @@
# Customer and subuser lifecycle visual comparisons
## Forgot-password account selection
The reset page previously accepted only a customer number. It now lets the
visitor choose a customer or chauffeur account. Chauffeur recovery uses the
country code and phone number and sends the one-time reset link by SMS.
- Mobile: [before](before-mobile.png) / [after](after-mobile.png)
- Tablet: [before](before-tablet.png) / [after](after-tablet.png)
- Desktop: [before](before-desktop.png) / [after](after-desktop.png)
## Pre-authorized customer access decision
The SMS link previously had no destination view. It now opens a read-only
request preview, identifies the chauffeur and customer, and requires an
explicit approve or deny action before the one-time token mutates access.
- Mobile: [before](access-before-mobile.png) / [after](access-after-mobile.png)
- Tablet: [before](access-before-tablet.png) / [after](access-after-tablet.png)
- Desktop: [before](access-before-desktop.png) / [after](access-after-desktop.png)
The related signed-in profile and customer grant selector use the same
responsive components. The selector is deduplicated by customer number and
uses colored permission indicators for vehicles, tools, calendar, orders,
and driver access.
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+1 -1
View File
@@ -98,7 +98,7 @@ const shouldHideGlobalOverlays = computed(
);
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
const guestSessionLoaderRoutePattern =
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|subuser-access\/decision$|qr\/new-(?:customer|driver)$)/;
const currentBrowserPath = () => {
if (typeof window === "undefined") {
return "";
@@ -1,247 +1,262 @@
<script setup>
import { computed, watch } from 'vue'
import { computed, watch } from "vue";
import i18n from "@/i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { IS_DEV } from '@/config.js';
import {
normalizeSubuserGrantOptions,
permissionGroupsForGrant,
} from "@/components/session/subuser/subuserGrantOptions.js";
defineProps({
showLabel: { type: Boolean, default: true },
compact: { type: Boolean, default: false },
});
const grants = computed(() => SessionUser.subuser.grants.value || []);
const t = (key, values = undefined) => i18n.global.t(key, values);
const selectedCustomerNumber = computed(() => SessionUser.subuser.selectedGrantCustomerNumber.value);
const isSubuser = computed(() => SessionUser.isSubuser.value);
// Auto-select if subuser has only one grant
const grants = computed(() => normalizeSubuserGrantOptions(SessionUser.subuser.grants.value));
watch(
() => grants.value,
grants,
(newGrants) => {
if (isSubuser.value && newGrants.length === 1 && !selectedCustomerNumber.value) {
SessionUser.subuser.selectGrant(newGrants[0].billing_customer_number);
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 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 selectedGrant = computed(() => {
if (!selectedCustomerNumber.value) return null;
return grants.value.find(g => g.billing_customer_number === selectedCustomerNumber.value);
});
const selectedGrantName = computed(() => {
if (!selectedGrant.value) return 'Vælg kunde';
return selectedGrant.value.name || 'Kunde #' + selectedGrant.value.billing_customer_number;
});
const visiblePermissionGroups = permissionGroupsForGrant;
</script>
<template>
<!-- No permissions message for subusers without grants -->
<div class="grant-selector no-permissions" :class="{ 'is-compact': compact }" v-if="isSubuser && grants.length === 0">
<div v-if="compact" class="no-permissions-compact">
<span class="icon has-text-warning">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span class="has-text-grey">Ingen tilladelser</span>
</div>
<div v-else class="notification is-warning is-light">
<span class="icon">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>Du har ingen tilladelser tildelt. Kontakt venligst din administrator for at adgang.</span>
</div>
<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 class="grant-selector" :class="{ 'is-compact': compact }" v-else-if="isSubuser && grants.length > 0">
<label v-if="showLabel" class="label">Vælg kunde</label>
<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>
<!-- Compact mode: Button-style like user element in header -->
<div v-if="compact" class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button class="button is-white" aria-haspopup="true" aria-controls="grant-dropdown-menu">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
<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>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ selectedGrantName }}</span>
<span class="icon is-small">
<i class="fas fa-angle-down" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="grant-dropdown-menu" role="menu">
<div class="dropdown-content">
<a
</b-button>
</template>
<b-dropdown-item
v-for="grant in grants"
:key="grant.billing_customer_number"
class="dropdown-item"
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="dropdown-item-check">
<i class="fas fa-check" v-if="grant.billing_customer_number === selectedCustomerNumber"></i>
<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="dropdown-item-text">{{ grant.name || 'Kunde #' + grant.billing_customer_number }}</span>
</a>
</div>
</div>
</div>
<!-- Full mode: Select with icon prefix -->
<div v-else class="field has-addons is-fullwidth">
<p class="control">
<span class="button is-static">
<span class="icon">
<i class="fas fa-building"></i>
<span class="grant-selector__option-copy">
<strong>{{ customerLabel(grant) }}</strong>
<small>#{{ grant.billing_customer_number }}</small>
</span>
</span>
</p>
<p class="control is-expanded">
<span class="select is-fullwidth">
<select
:value="selectedCustomerNumber"
@change="selectGrant($event.target.value ? parseInt($event.target.value) : null)"
<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"
>
<option :value="null" disabled>-- Vælg kunde --</option>
<option
v-for="grant in grants"
:key="grant.billing_customer_number"
:value="grant.billing_customer_number"
>
{{ grant.name || 'Kunde #' + grant.billing_customer_number }}
</option>
</select>
<span class="grant-selector__permission-icon" :class="group.color">
<i :class="group.icon"></i>
</span>
</p>
</div>
</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.is-compact .dropdown-trigger .button {
border: none;
box-shadow: none;
.grant-selector__dropdown,
.grant-selector__trigger {
width: 100%;
}
.grant-selector.is-compact .dropdown-menu {
min-width: 250px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
.grant-selector__trigger {
justify-content: flex-start;
}
.grant-selector.is-compact .dropdown-content {
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 0.5rem 0;
}
.grant-selector.is-compact .dropdown-item {
cursor: pointer;
padding: 0.625rem 1rem;
font-size: 0.9rem;
transition: background-color 0.15s ease, color 0.15s ease;
border-radius: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.grant-selector.is-compact .dropdown-item:hover {
background-color: #f5f5f5;
}
.grant-selector.is-compact .dropdown-item.is-active {
background-color: #3273dc;
color: white;
font-weight: 500;
}
.grant-selector.is-compact .dropdown-item.is-active:hover {
background-color: #2366d1;
}
.grant-selector.is-compact .dropdown-item-check {
width: 1.25rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #3273dc;
}
.grant-selector.is-compact .dropdown-item.is-active .dropdown-item-check {
color: white;
}
.grant-selector.is-compact .dropdown-item-text {
flex: 1;
white-space: nowrap;
.grant-selector__trigger-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label {
font-weight: 600;
margin-bottom: 0.5rem;
display: block;
.grant-selector__trigger-icons {
display: inline-flex;
gap: 0.35rem;
margin-left: auto;
}
.field.has-addons {
.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;
}
.button.is-static {
background-color: #f5f5f5;
border-color: #dbdbdb;
@media (max-width: 768px) {
.grant-selector__trigger-icons {
display: none;
}
.transform-color-black {
filter: invert(100%) grayscale(100%) brightness(0) contrast(100%);
.grant-selector__option {
grid-template-columns: 1.25rem minmax(6rem, 1fr);
}
.transform-color-red {
filter: invert(27%) sepia(96%) saturate(7493%) hue-rotate(357deg) brightness(103%) contrast(101%);
.grant-selector__permissions {
grid-column: 2;
}
.help {
font-size: 0.85rem;
color: #666;
margin-top: 0.5rem;
}
/* No permissions styles */
.no-permissions .notification {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.no-permissions-compact {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
}
.no-permissions-compact .icon {
font-size: 0.9rem;
}
.no-permissions-compact span:not(.icon) {
font-size: 0.9rem;
}
</style>
@@ -0,0 +1,38 @@
export const SUBUSER_PERMISSION_GROUPS = [
{ key: "vehicles", prefix: "VEHICLES_", icon: "fas fa-truck", color: "has-text-info" },
{ key: "selfserve", prefix: "SELFSERVE_", icon: "fas fa-toolbox", color: "has-text-primary" },
{ key: "bookings", prefix: "BOOKINGS_", icon: "fas fa-calendar-check", color: "has-text-success" },
{ key: "orders", prefix: "ORDERS_", icon: "fas fa-receipt", color: "has-text-warning-dark" },
{ key: "driver_management", prefix: "SUBUSERS_", icon: "fas fa-users-cog", color: "has-text-link" },
];
export const normalizeSubuserGrantOptions = (source = []) => {
const byCustomer = new Map();
for (const grant of source || []) {
const customerNumber = Number(grant?.billing_customer_number);
if (!Number.isInteger(customerNumber) || customerNumber <= 0) continue;
const existing = byCustomer.get(customerNumber);
byCustomer.set(customerNumber, {
...(existing || {}),
...grant,
billing_customer_number: customerNumber,
enabled: Boolean(existing?.enabled || grant?.enabled),
name: String(existing?.name || grant?.name || "").trim(),
permissions: [...new Set([...(existing?.permissions || []), ...(grant?.permissions || [])])],
});
}
return [...byCustomer.values()].sort((left, right) => {
const leftName = left.name || String(left.billing_customer_number);
const rightName = right.name || String(right.billing_customer_number);
return leftName.localeCompare(rightName, undefined, { numeric: true, sensitivity: "base" });
});
};
export const permissionGroupsForGrant = (grant) => {
const permissions = new Set((grant?.permissions || []).map((permission) => String(permission).toUpperCase()));
return SUBUSER_PERMISSION_GROUPS.filter((group) =>
[...permissions].some((permission) => permission.startsWith(group.prefix))
);
};
+13
View File
@@ -5034,7 +5034,11 @@
"unnamed": "Unavngivet @:{'words.generated.passkey'}"
},
"password_reset": {
"customer_account": "Kundekonto",
"driver_account": "Chaufførkonto",
"new_password": "@:{'templates.generated.compat.settings.new_password'}",
"phone": "Telefonnummer",
"phone_country_code": "Landekode",
"restart": "Genstart nulstilling @:{'words.generated.af'} @:{'words.generated.adgangskode'}",
"submit": "@:{'words.generated.nulstil'} @:{'words.generated.adgangskode'}"
},
@@ -5636,6 +5640,15 @@
"vehicle_types": "@.capitalize:{'words.generated.køretøjstyper'}",
"washes": "@:{'templates.generated.compat.global.wash_multiple'}"
},
"subuser_access_decision": {
"approve": "Godkend adgang",
"approved": "Chaufførens adgang er godkendt.",
"deny": "Afvis adgang",
"denied": "Chaufførens adgang er afvist.",
"description": "{name} anmoder om adgang til {customer}. Kontrollér oplysningerne, før du fortsætter.",
"expired": "Linket er ugyldigt, udløbet eller allerede brugt.",
"title": "Adgangsanmodning"
},
"super_user_dashboard": {
"create_department": "@:{'words.generated.opret'} @:{'words.generated.afdeling'}",
"create_number_plate_scan": "@:{'words.generated.opret'} @:{'words.generated.nummerplade'} @:{'words.generated.scanning'}",
+13
View File
@@ -5144,7 +5144,11 @@
"unnamed": "Unbenannter @:{'words.generated.passkey'}"
},
"password_reset": {
"customer_account": "Kundenkonto",
"driver_account": "Fahrerkonto",
"new_password": "@:{'templates.generated.compat.settings.new_password'}",
"phone": "Telefonnummer",
"phone_country_code": "Ländervorwahl",
"restart": "@:{'words.generated.passwort'}-Reset @:{'words.generated.neu'} @:{'words.generated.starten'}",
"submit": "@:{'words.generated.passwort'} @:{'words.generated.zurucksetzen'}"
},
@@ -5746,6 +5750,15 @@
"vehicle_types": "@:{'words.generated.fahrzeugtypen'}",
"washes": "Tvättar"
},
"subuser_access_decision": {
"approve": "Zugriff genehmigen",
"approved": "Der Fahrerzugriff wurde genehmigt.",
"deny": "Zugriff ablehnen",
"denied": "Der Fahrerzugriff wurde abgelehnt.",
"description": "{name} beantragt Zugriff auf {customer}. Prüfen Sie die Angaben, bevor Sie fortfahren.",
"expired": "Dieser Link ist ungültig, abgelaufen oder wurde bereits verwendet.",
"title": "Zugriffsanfrage"
},
"super_user_dashboard": {
"create_department": "@:{'words.generated.abteilung'} @:{'words.generated.erstellen'}",
"create_number_plate_scan": "@:{'words.generated.kennzeichenscan'} @:{'words.generated.erstellen'}",
+13
View File
@@ -4865,7 +4865,11 @@
"unnamed": "Unnamed @:{'words.generated.passkey'}"
},
"password_reset": {
"customer_account": "Customer account",
"driver_account": "Driver account",
"new_password": "@:{'templates.generated.compat.settings.new_password'}",
"phone": "Phone number",
"phone_country_code": "Country code",
"restart": "Restart @:{'words.generated.reset'} @:{'words.generated.password'}",
"submit": "@.capitalize:{'words.generated.reset'} @:{'words.generated.password'}"
},
@@ -5467,6 +5471,15 @@
"vehicle_types": "@.capitalize:{'words.generated.vehicle'} @:{'words.generated.types'}",
"washes": "@:{'templates.generated.compat.global.wash_multiple'}"
},
"subuser_access_decision": {
"approve": "Approve access",
"approved": "The driver's access was approved.",
"deny": "Deny access",
"denied": "The driver's access was denied.",
"description": "{name} requests access to {customer}. Verify the details before continuing.",
"expired": "This link is invalid, expired, or has already been used.",
"title": "Access request"
},
"super_user_dashboard": {
"create_department": "@.capitalize:{'words.generated.create'} @:{'words.generated.department'}",
"create_number_plate_scan": "@:{'words.generated.opret'} nummerplade @:{'words.generated.scanning'}",
+13
View File
@@ -4355,8 +4355,12 @@
"password_reset": {
"back_to_login": "@:{'templates.generated.compat.auth.customer_login_title'}",
"change_password": "@:{'templates.generated.compat.global.edit'}",
"customer_account": "@:{'templates.generated.compat.password_reset.customer_account'}",
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
"driver_account": "@:{'templates.generated.compat.password_reset.driver_account'}",
"new_password": "@:{'templates.generated.compat.password_reset.new_password'}",
"phone": "@:{'templates.generated.compat.password_reset.phone'}",
"phone_country_code": "@:{'templates.generated.compat.password_reset.phone_country_code'}",
"restart": "@:{'templates.generated.compat.password_reset.restart'}",
"submit": "@:{'templates.generated.compat.password_reset.submit'}",
"title": "@:password_reset.submit"
@@ -5273,6 +5277,15 @@
"vehicles": "@:{'templates.generated.compat.common.vehicles'}",
"washes": "@:{'templates.generated.compat.statistics.washes'}"
},
"subuser_access_decision": {
"approve": "@:{'templates.generated.compat.subuser_access_decision.approve'}",
"approved": "@:{'templates.generated.compat.subuser_access_decision.approved'}",
"deny": "@:{'templates.generated.compat.subuser_access_decision.deny'}",
"denied": "@:{'templates.generated.compat.subuser_access_decision.denied'}",
"description": "@:{'templates.generated.compat.subuser_access_decision.description'}",
"expired": "@:{'templates.generated.compat.subuser_access_decision.expired'}",
"title": "@:{'templates.generated.compat.subuser_access_decision.title'}"
},
"super_user_dashboard": {
"create_department": "@:{'templates.generated.compat.super_user_dashboard.create_department'}",
"create_number_plate_scan": "@:{'templates.generated.compat.super_user_dashboard.create_number_plate_scan'}",
+13
View File
@@ -5147,7 +5147,11 @@
"unnamed": "Navnløs @:{'words.generated.passnøkkel'}"
},
"password_reset": {
"customer_account": "Kundekonto",
"driver_account": "Sjåførkonto",
"new_password": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.passord'}",
"phone": "Telefonnummer",
"phone_country_code": "Landskode",
"restart": "@.capitalize:{'words.generated.start'} @:{'words.generated.tilbakestill'} @:{'words.generated.passord'} @:{'words.generated.pa'} @:{'words.generated.nytt'}",
"submit": "@:{'words.generated.nullstill'} @:{'words.generated.passord'}"
},
@@ -5749,6 +5753,15 @@
"vehicle_types": "@.capitalize:{'words.generated.kjøretøytyper'}",
"washes": "Tvätter"
},
"subuser_access_decision": {
"approve": "Godkjenn tilgang",
"approved": "Sjåførens tilgang ble godkjent.",
"deny": "Avslå tilgang",
"denied": "Sjåførens tilgang ble avslått.",
"description": "{name} ber om tilgang til {customer}. Kontroller opplysningene før du fortsetter.",
"expired": "Lenken er ugyldig, utløpt eller allerede brukt.",
"title": "Tilgangsforespørsel"
},
"super_user_dashboard": {
"create_department": "@:{'words.generated.opprett'} @:{'words.generated.avdeling'}",
"create_number_plate_scan": "@:{'words.generated.lag'} @:{'words.generated.skanning'} @:{'words.generated.av'} @:{'words.generated.nummerskilt'}",
+13
View File
@@ -5197,7 +5197,11 @@
"unnamed": "Namnlös @:{'words.generated.losennyckel'}"
},
"password_reset": {
"customer_account": "Kundkonto",
"driver_account": "Förarkonto",
"new_password": "@.capitalize:{'words.generated.new'} @:{'words.generated.password'}",
"phone": "Telefonnummer",
"phone_country_code": "Landskod",
"restart": "Restart @:{'words.generated.reset'} @:{'words.generated.password'}",
"submit": "@:{'words.generated.aterstall'} @:{'words.generated.losenord'}"
},
@@ -5799,6 +5803,15 @@
"vehicle_types": "@.capitalize:{'words.generated.fordonstyper'}",
"washes": "@:{'templates.generated.compat.global.wash_multiple'}"
},
"subuser_access_decision": {
"approve": "Godkänn åtkomst",
"approved": "Förarens åtkomst godkändes.",
"deny": "Neka åtkomst",
"denied": "Förarens åtkomst nekades.",
"description": "{name} begär åtkomst till {customer}. Kontrollera uppgifterna innan du fortsätter.",
"expired": "Länken är ogiltig, har gått ut eller har redan använts.",
"title": "Åtkomstbegäran"
},
"super_user_dashboard": {
"create_department": "@.capitalize:{'words.generated.skapa'} @:{'words.generated.avdelning'}",
"create_number_plate_scan": "@:{'words.generated.opret'} @:{'words.generated.nummerplade'} @:{'words.generated.scanning'}",
@@ -1,7 +1,11 @@
{
"compat": {
"password_reset": {
"customer_account": "Kundekonto",
"driver_account": "Chaufførkonto",
"new_password": "@:{'phrases.compat.settings.new_password'}",
"phone": "Telefonnummer",
"phone_country_code": "Landekode",
"restart": "Genstart nulstilling @:{'terms.glossary.af'} @:{'terms.glossary.adgangskode'}",
"submit": "@:{'terms.glossary.nulstil'} @:{'terms.glossary.adgangskode'}"
}
@@ -0,0 +1,13 @@
{
"compat": {
"subuser_access_decision": {
"approve": "Godkend adgang",
"approved": "Chaufførens adgang er godkendt.",
"deny": "Afvis adgang",
"denied": "Chaufførens adgang er afvist.",
"description": "{name} anmoder om adgang til {customer}. Kontrollér oplysningerne, før du fortsætter.",
"expired": "Linket er ugyldigt, udløbet eller allerede brugt.",
"title": "Adgangsanmodning"
}
}
}
@@ -1,7 +1,11 @@
{
"compat": {
"password_reset": {
"customer_account": "Kundenkonto",
"driver_account": "Fahrerkonto",
"new_password": "@:{'phrases.compat.settings.new_password'}",
"phone": "Telefonnummer",
"phone_country_code": "Ländervorwahl",
"restart": "@:{'terms.glossary.passwort'}-Reset @:{'terms.glossary.neu'} @:{'terms.glossary.starten'}",
"submit": "@:{'terms.glossary.passwort'} @:{'terms.glossary.zurucksetzen'}"
}
@@ -0,0 +1,13 @@
{
"compat": {
"subuser_access_decision": {
"approve": "Zugriff genehmigen",
"approved": "Der Fahrerzugriff wurde genehmigt.",
"deny": "Zugriff ablehnen",
"denied": "Der Fahrerzugriff wurde abgelehnt.",
"description": "{name} beantragt Zugriff auf {customer}. Prüfen Sie die Angaben, bevor Sie fortfahren.",
"expired": "Dieser Link ist ungültig, abgelaufen oder wurde bereits verwendet.",
"title": "Zugriffsanfrage"
}
}
}
@@ -1,7 +1,11 @@
{
"compat": {
"password_reset": {
"customer_account": "Customer account",
"driver_account": "Driver account",
"new_password": "@:{'phrases.compat.settings.new_password'}",
"phone": "Phone number",
"phone_country_code": "Country code",
"restart": "Restart @:{'terms.glossary.reset'} @:{'terms.glossary.password'}",
"submit": "@.capitalize:{'terms.glossary.reset'} @:{'terms.glossary.password'}"
}
@@ -0,0 +1,13 @@
{
"compat": {
"subuser_access_decision": {
"approve": "Approve access",
"approved": "The driver's access was approved.",
"deny": "Deny access",
"denied": "The driver's access was denied.",
"description": "{name} requests access to {customer}. Verify the details before continuing.",
"expired": "This link is invalid, expired, or has already been used.",
"title": "Access request"
}
}
}
@@ -2,8 +2,12 @@
"password_reset": {
"back_to_login": "@:{'phrases.compat.auth.customer_login_title'}",
"change_password": "@:{'phrases.compat.global.edit'}",
"customer_account": "@:{'phrases.compat.password_reset.customer_account'}",
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"driver_account": "@:{'phrases.compat.password_reset.driver_account'}",
"new_password": "@:{'phrases.compat.password_reset.new_password'}",
"phone": "@:{'phrases.compat.password_reset.phone'}",
"phone_country_code": "@:{'phrases.compat.password_reset.phone_country_code'}",
"restart": "@:{'phrases.compat.password_reset.restart'}",
"submit": "@:{'phrases.compat.password_reset.submit'}",
"title": "@:password_reset.submit"
@@ -0,0 +1,11 @@
{
"subuser_access_decision": {
"approve": "@:{'phrases.compat.subuser_access_decision.approve'}",
"approved": "@:{'phrases.compat.subuser_access_decision.approved'}",
"deny": "@:{'phrases.compat.subuser_access_decision.deny'}",
"denied": "@:{'phrases.compat.subuser_access_decision.denied'}",
"description": "@:{'phrases.compat.subuser_access_decision.description'}",
"expired": "@:{'phrases.compat.subuser_access_decision.expired'}",
"title": "@:{'phrases.compat.subuser_access_decision.title'}"
}
}
@@ -1,7 +1,11 @@
{
"compat": {
"password_reset": {
"customer_account": "Kundekonto",
"driver_account": "Sjåførkonto",
"new_password": "@.capitalize:{'terms.glossary.nytt'} @:{'terms.glossary.passord'}",
"phone": "Telefonnummer",
"phone_country_code": "Landskode",
"restart": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.tilbakestill'} @:{'terms.glossary.passord'} @:{'terms.glossary.pa'} @:{'terms.glossary.nytt'}",
"submit": "@:{'terms.glossary.nullstill'} @:{'terms.glossary.passord'}"
}
@@ -0,0 +1,13 @@
{
"compat": {
"subuser_access_decision": {
"approve": "Godkjenn tilgang",
"approved": "Sjåførens tilgang ble godkjent.",
"deny": "Avslå tilgang",
"denied": "Sjåførens tilgang ble avslått.",
"description": "{name} ber om tilgang til {customer}. Kontroller opplysningene før du fortsetter.",
"expired": "Lenken er ugyldig, utløpt eller allerede brukt.",
"title": "Tilgangsforespørsel"
}
}
}
@@ -1,7 +1,11 @@
{
"compat": {
"password_reset": {
"customer_account": "Kundkonto",
"driver_account": "Förarkonto",
"new_password": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.password'}",
"phone": "Telefonnummer",
"phone_country_code": "Landskod",
"restart": "Restart @:{'terms.glossary.reset'} @:{'terms.glossary.password'}",
"submit": "@:{'terms.glossary.aterstall'} @:{'terms.glossary.losenord'}"
}
@@ -0,0 +1,13 @@
{
"compat": {
"subuser_access_decision": {
"approve": "Godkänn åtkomst",
"approved": "Förarens åtkomst godkändes.",
"deny": "Neka åtkomst",
"denied": "Förarens åtkomst nekades.",
"description": "{name} begär åtkomst till {customer}. Kontrollera uppgifterna innan du fortsätter.",
"expired": "Länken är ogiltig, har gått ut eller har redan använts.",
"title": "Åtkomstbegäran"
}
}
}
+6
View File
@@ -267,6 +267,12 @@ export const router = createRouter({
component: SubuserCompleteRegistrationPage,
meta: { template: 'with-header' }
},
{
name: 'subuser-access-decision',
path: '/subuser-access/decision',
component: () => import('@/views/pages/auth/SubuserAccessDecisionPage.vue'),
meta: { template: 'with-header' }
},
{
name: 'privacy-policy',
path: '/privacy-policy',
@@ -426,8 +426,12 @@ const onClickChangePassword = async () => {
Swal.showValidationMessage("De nye adgangskoder matcher ikke");
return;
}
if (newPassword.length < 4) {
Swal.showValidationMessage("Den nye adgangskode skal være mindst 4 tegn lang");
if (newPassword.length < (SessionUser.isSubuser.value ? 8 : 4)) {
Swal.showValidationMessage(
SessionUser.isSubuser.value
? "Den nye adgangskode skal være mindst 8 tegn og indeholde stort bogstav, lille bogstav og tal"
: "Den nye adgangskode skal være mindst 4 tegn lang"
);
return;
}
isLoading.value = true;
@@ -440,10 +444,13 @@ const onClickChangePassword = async () => {
if (result.isConfirmed) {
const { currentPassword, newPassword } = result.value;
// Send the change password request
SessionUser.request("/account/security/change-password", "POST", {
current_password: currentPassword,
new_password: newPassword,
})
SessionUser.request(
SessionUser.isSubuser.value ? "/subusers/me/password" : "/account/security/change-password",
"POST",
SessionUser.isSubuser.value
? { current_password: currentPassword, password: newPassword }
: { current_password: currentPassword, new_password: newPassword }
)
.then((response) => {
if (response.status === 200) {
Swal.fire({
@@ -998,6 +1005,16 @@ const onClickSaveWashCertificateEmail = async () => {
icon="fas fa-shield-alt"
data-testid="subuser-profile-security-card"
>
<button
class="button is-link is-fullwidth-mobile mb-4"
@click="onClickChangePassword"
:disabled="isLoading"
data-testid="subuser-profile-security-change-password"
>
<span class="icon is-small"><i class="fas fa-key"></i></span>
<span>{{ $t("user_dashboard.profile.security.change_password") }}</span>
</button>
<!-- Sign out -->
<button
class="button is-danger is-outlined mt-2 profile-logout-button"
@@ -9,6 +9,9 @@ import {API_URL} from "@/config.js";
const router = useRouter();
const token = ref(router.currentRoute.value.params.token);
const isSubuserReset = () => router.currentRoute.value.query.type === "subuser";
const resetEndpoint = (action) =>
API_URL + (isSubuserReset() ? `/subusers/password-reset/${action}` : `/auth/password-reset/${action}`);
const isTokenValid = ref(null);
const newPassword = ref(null);
const hasSetPassword = ref(false);
@@ -19,7 +22,7 @@ const timeoutId = ref(null)
const resetPasswordValidate = async () => {
removeError('resetPasswordValidate')
try {
await axios.get(API_URL + '/auth/password-reset/validate', {
await axios.get(resetEndpoint("validate"), {
params: {
token: token.value,
}
@@ -39,7 +42,7 @@ const resetPasswordValidate = async () => {
const createNewPassword = async () => {
removeError('resetPasswordConfirm')
try {
const response = await axios.post(API_URL + '/auth/password-reset/set', {
const response = await axios.post(resetEndpoint("set"), {
token: token.value,
password: newPassword.value
});
@@ -63,6 +66,8 @@ watch(() => router.currentRoute.value.params.token, (newVal) => {
});
onMounted(() => {
const resetType = isSubuserReset() ? "?type=subuser" : "";
window.history.replaceState(window.history.state, "", `/auth/password-reset${resetType}`);
resetPasswordValidate()
})
+53 -5
View File
@@ -7,6 +7,9 @@ import { parseError, getError, removeError } from "@/components/request/HandleGl
import axios from "axios";
import {API_URL} from "@/config.js";
const customer_number = ref(null)
const accountType = ref("customer")
const phoneCountryCode = ref(45)
const phone = ref(null)
const message = ref(null)
const timeoutId = ref(null)
@@ -14,9 +17,16 @@ const timeoutId = ref(null)
const resetPassword = async () => {
removeError('resetPassword')
try {
const response = await axios.post(API_URL + '/auth/password-reset/request', {
customer_number: customer_number.value,
});
const isDriver = accountType.value === "subuser";
const response = await axios.post(
API_URL + (isDriver ? "/subusers/password-reset/request" : "/auth/password-reset/request"),
isDriver
? {
phone_country_code: Number(phoneCountryCode.value),
phone: Number(phone.value),
}
: { customer_number: customer_number.value }
);
message.value = response.data.data.message;
timeoutId.value = setTimeout(() => {
@@ -43,10 +53,43 @@ onBeforeUnmount(() => {
<img :src="logo" alt="Truck Wash">
</router-link>
<form @submit.prevent="() => {}">
<div class="form-field">
<b-field grouped class="account-type-toggle">
<b-radio-button v-model="accountType" native-value="customer" type="is-info">
<b-icon icon="building" />
<span>{{ $t("password_reset.customer_account") }}</span>
</b-radio-button>
<b-radio-button v-model="accountType" native-value="subuser" type="is-primary">
<b-icon icon="id-card" />
<span>{{ $t("password_reset.driver_account") }}</span>
</b-radio-button>
</b-field>
<div v-if="accountType === 'customer'" class="form-field">
<label class="label">{{ $t('password_reset.customer_number') }}</label>
<input type="text" v-model="customer_number" name="customer_number" id="password_reset_customer_number_input" />
</div>
<div v-else class="columns is-mobile">
<div class="column is-4 form-field">
<label class="label">{{ $t("password_reset.phone_country_code") }}</label>
<input
v-model="phoneCountryCode"
type="number"
inputmode="numeric"
autocomplete="tel-country-code"
id="password_reset_phone_country_code_input"
/>
</div>
<div class="column form-field">
<label class="label">{{ $t("password_reset.phone") }}</label>
<input
v-model="phone"
type="tel"
inputmode="tel"
autocomplete="tel-national"
id="password_reset_phone_input"
/>
</div>
</div>
<p class="help is-danger" v-if="getError('resetPassword')" id="password_reset_alert_error">
{{ getError('resetPassword') }}
@@ -56,7 +99,12 @@ onBeforeUnmount(() => {
{{ message }}
</p>
<LoadButtonWhileAwait :disabled="!customer_number" :loadFunction="resetPassword" class="submit" id="password-reset-button">{{ $t('password_reset.submit') }}</LoadButtonWhileAwait>
<LoadButtonWhileAwait
:disabled="accountType === 'customer' ? !customer_number : !phoneCountryCode || !phone"
:loadFunction="resetPassword"
class="submit"
id="password-reset-button"
>{{ $t('password_reset.submit') }}</LoadButtonWhileAwait>
<router-link :to="{name: 'login'}" class="submit">{{ $t('password_reset.back_to_login') }}</router-link>
</form>
</div>
@@ -0,0 +1,159 @@
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import axios from "axios";
import { API_URL } from "@/config.js";
import logo from "@/assets/img/truckwash-banner.png";
import i18n from "@/i18n";
const route = useRoute();
const t = (key, values = undefined) => i18n.global.t(key, values);
const token = ref(String(route.query.token || ""));
const request = ref(null);
const loading = ref(true);
const submitting = ref(false);
const error = ref(false);
const result = ref(null);
const loadRequest = async () => {
loading.value = true;
error.value = false;
try {
const response = await axios.get(`${API_URL}/subusers/access-decision`, {
params: { token: token.value },
});
request.value = response.data.data;
} catch {
error.value = true;
} finally {
loading.value = false;
}
};
const confirmDecision = async () => {
submitting.value = true;
try {
const response = await axios.post(`${API_URL}/subusers/access-decision`, {
token: token.value,
});
result.value = response.data.data.decision;
request.value = null;
} catch {
error.value = true;
} finally {
submitting.value = false;
}
};
onMounted(() => {
// Keep the bearer token in memory only after bootstrap so it is not retained
// in browser history, screenshots, copied URLs, or subsequent referrers.
window.history.replaceState(window.history.state, "", window.location.pathname);
loadRequest();
});
</script>
<template>
<section class="decision-page">
<article class="decision-card box">
<router-link :to="{ name: 'landing' }" class="decision-card__logo">
<img :src="logo" alt="Truck Wash" />
</router-link>
<b-loading :is-full-page="false" :active="loading" />
<b-message v-if="error" type="is-danger" has-icon>
{{ t("subuser_access_decision.expired") }}
</b-message>
<b-message v-else-if="result" :type="result === 'approved' ? 'is-success' : 'is-warning'" has-icon>
{{
t(
result === "approved"
? "subuser_access_decision.approved"
: "subuser_access_decision.denied"
)
}}
</b-message>
<template v-else-if="request">
<div class="decision-card__icon" :class="request.action === 'approve' ? 'is-approve' : 'is-deny'">
<i :class="request.action === 'approve' ? 'fas fa-user-check' : 'fas fa-user-times'"></i>
</div>
<h1 class="title is-3">{{ t("subuser_access_decision.title") }}</h1>
<p>
{{
t("subuser_access_decision.description", {
name: request.subuser_name,
customer: request.customer_name,
})
}}
</p>
<b-button
class="mt-5"
expanded
:type="request.action === 'approve' ? 'is-success' : 'is-danger'"
:icon-left="request.action === 'approve' ? 'check' : 'times'"
:loading="submitting"
data-testid="subuser-access-decision-confirm"
@click="confirmDecision"
>
{{
t(
request.action === "approve"
? "subuser_access_decision.approve"
: "subuser_access_decision.deny"
)
}}
</b-button>
</template>
</article>
</section>
</template>
<style scoped>
.decision-page {
align-items: center;
background: linear-gradient(145deg, #eef4f8, #fff);
display: flex;
justify-content: center;
min-height: 100vh;
padding: 1.25rem;
}
.decision-card {
max-width: 34rem;
min-height: 20rem;
padding: clamp(1.5rem, 5vw, 3rem);
position: relative;
text-align: center;
width: 100%;
}
.decision-card__logo {
display: inline-block;
margin-bottom: 2rem;
max-width: 13rem;
}
.decision-card__icon {
align-items: center;
border-radius: 999px;
display: inline-flex;
font-size: 1.8rem;
height: 4rem;
justify-content: center;
margin-bottom: 1rem;
width: 4rem;
}
.decision-card__icon.is-approve {
background: #e8f7ee;
color: #168447;
}
.decision-card__icon.is-deny {
background: #fff0f0;
color: #c83b46;
}
</style>
+33
View File
@@ -177,6 +177,17 @@ async function installPasswordResetRequestRoute(page, message = PASSWORD_RESET_M
});
}
async function installSubuserPasswordResetRequestRoute(page, onRequest) {
await page.route("**/subusers/password-reset/request", async (route) => {
onRequest?.(route.request().postDataJSON());
await json(route, {
data: {
message: "Hvis chaufførkontoen findes, er et nulstillingslink sendt.",
},
});
});
}
async function installPasswordResetValidationRoute(page, { invalidToken = "invalidtoken" } = {}) {
await page.route("**/auth/password-reset/validate*", async (route) => {
const url = new URL(route.request().url());
@@ -345,6 +356,28 @@ test("[AUTH][User][Customer number] should not show failure to request password
await expect(page.locator("#password_reset_alert_success")).toHaveText(PASSWORD_RESET_MESSAGE);
});
test("[AUTH][Subuser][Phone number] should request password reset without exposing account state", async ({ page }) => {
await preparePublicAuthFlow(page);
let payload = null;
await installSubuserPasswordResetRequestRoute(page, (requestPayload) => {
payload = requestPayload;
});
await openAuthPage(page, "/auth/password-reset", "#password_reset_customer_number_input");
await page.getByText("Chaufførkonto", { exact: true }).click();
await page.fill("#password_reset_phone_country_code_input", "45");
await page.fill("#password_reset_phone_input", "12345678");
await page.click("#password-reset-button");
await expect(page.locator("#password_reset_alert_success")).toHaveText(
"Hvis chaufførkontoen findes, er et nulstillingslink sendt."
);
expect(payload).toEqual({
phone_country_code: 45,
phone: 12345678,
});
});
test("[AUTH][User][Customer number] should fail to reset password using invalid token", async ({ page }) => {
await preparePublicAuthFlow(page);
await installPasswordResetValidationRoute(page);
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
normalizeSubuserGrantOptions,
permissionGroupsForGrant,
} from "@/components/session/subuser/subuserGrantOptions.js";
describe("subuser grant options", () => {
it("deduplicates customer rows while preserving the best name and all permissions", () => {
const result = normalizeSubuserGrantOptions([
{
billing_customer_number: 200,
name: "Beta Logistics",
enabled: false,
permissions: ["VEHICLES_LIST"],
},
{
billing_customer_number: 100,
name: "Alpha Transport",
enabled: true,
permissions: ["BOOKINGS_LIST"],
},
{
billing_customer_number: 200,
name: "",
enabled: true,
permissions: ["SELFSERVE_ADD", "VEHICLES_LIST"],
},
]);
expect(result).toHaveLength(2);
expect(result.map((grant) => grant.billing_customer_number)).toEqual([100, 200]);
expect(result[1]).toMatchObject({
name: "Beta Logistics",
enabled: true,
permissions: ["VEHICLES_LIST", "SELFSERVE_ADD"],
});
});
it("maps capabilities to colored vehicle, toolbox, calendar, order, and driver icons", () => {
const groups = permissionGroupsForGrant({
permissions: ["vehicles_list", "SELFSERVE_ADD", "BOOKINGS_EDIT", "ORDERS_LIST", "SUBUSERS_LIST"],
});
expect(groups.map((group) => group.key)).toEqual([
"vehicles",
"selfserve",
"bookings",
"orders",
"driver_management",
]);
expect(groups.map((group) => group.icon)).toContain("fas fa-toolbox");
expect(groups.map((group) => group.icon)).toContain("fas fa-calendar-check");
});
});