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:**  **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>
@@ -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.
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -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 få 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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="grant-dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a
|
||||
v-for="grant in grants"
|
||||
:key="grant.billing_customer_number"
|
||||
class="dropdown-item"
|
||||
: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>
|
||||
<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>
|
||||
</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)"
|
||||
>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</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%);
|
||||
}
|
||||
|
||||
.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;
|
||||
.grant-selector__permissions {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
</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))
|
||||
);
|
||||
};
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||