Surface driver login on /login and /; make SubuserLogin responsive (#265)

## Why

Drivers (sub-users) could only reach `/login/driver` via the direct URL
— there was no UI affordance anywhere else. They had to know the URL or
be sent a link by their admin. Mobile / tablet users had no obvious path
to the driver login either.

## What changed

- `src/components/forms/auth/LoginForm.vue` — Add a clearly-clickable
**Driver login** button below the existing "Login with QR code" link.
Distinct color (`#1584BC`) and a truck icon separate it from the primary
customer login. Test ID `driver-login-link`, ID `driver-login-button`.
The button is reachable on every viewport (44px+ touch target, no
horizontal scroll on mobile).
- `src/views/pages/LandingPage.vue` — Add a secondary **driver entry**
block below the customer login form, in a tinted container (`#F2F8FC`
with `#BFE0EF` border) with the intro "Are you a driver? Log in here to
register a wash." Test ID `landing-driver-entry` /
`landing-driver-login-link`.
- `src/views/auth/SubuserLogin.vue` — Make the page responsive:
  - **Desktop (>1024px):** 33%/67% sidebar + main (unchanged).
  - **Tablet (≤1024px):** 25%/75% tighter split, smaller sidebar title.
- **Mobile (≤768px):** Stack the sidebar above main (full-width 140px
header band) so it never forces a horizontal scroll.
- `src/i18n/source/{global/shared,da,en,de,no,sv}/.../auth/index.json` —
Add `auth.driver_login_button` and `auth.driver_entry_intro` in 5
locales. Run `npm run i18n:v2:compile` to regenerate the v2 bundle.
- `tests/e2e/driverAuth.spec.ts` — New E2E suite covering:
  - `/login` shows the driver login button on desktop and mobile.
  - `/` shows the driver entry block.
- Clicking either entry navigates to `/login/driver` and the form is
usable (inputs reachable, submit button visible) on mobile.

## Verification

- `npx eslint` — clean for changed files.
- `npm run i18n:v2:check` — green after `i18n:v2:compile`.

## Caveats

- New `.driver-entry` and `.driver-login-link` styles are scoped to the
components; if a global theme override is required, lift to a shared
SCSS partial in a follow-up.
- The driver login button is placed below the customer login in the
form. On very tall mobile viewports it may sit below the fold; in
practice the form fits in the first scroll, but worth watching in
production analytics.

🤖 Generated with [OpenClaw](https://openclaw.ai)

---------

Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
This commit is contained in:
Jeppe B
2026-08-09 01:58:54 +02:00
committed by GitHub
co-authored by Truck Wash Agent
parent 6b44835347
commit d4f92cd259
17 changed files with 218 additions and 6 deletions
+1
View File
@@ -49,6 +49,7 @@ export const ownedFilesByRole = {
"userVehicles.spec.ts",
],
subuser: [
"driverAuth.spec.ts",
"subuserCompleteRegistration.spec.ts",
"subuserProfileContact.spec.ts",
"subuserProfileGrant.spec.ts",
+30
View File
@@ -205,6 +205,18 @@ onMounted(async () => {
<router-link :to="{name: 'password-reset'}" class="" id="forgot-password-button">{{ $t('auth.forgot_password') }}</router-link>
<LoadButtonWhileAwait :loadFunction="login" class="submit" id="login-button" data-testid="login-submit">{{ $t('auth.login') }}</LoadButtonWhileAwait>
<router-link :to="{name: 'loginqr'}" class="submit">{{ $t('auth.login_with_qr_code') }}</router-link>
<!-- Driver login entry point discoverable from any login surface so drivers
don't need the direct /login/driver URL. -->
<router-link
:to="{name: 'subuserlogin'}"
class="submit driver-login-link"
id="driver-login-button"
data-testid="driver-login-link"
>
<span class="icon"><i class="fas fa-truck"></i></span>
<span>{{ $t('auth.driver_login_button') }}</span>
</router-link>
<!-- Passkey login button -->
<button
@@ -281,4 +293,22 @@ form input:not([type="checkbox"]) {
.passkey-btn {
gap: 8px;
}
/* Driver login button — visually distinct from the primary submit so drivers
notice it as a separate path. */
.driver-login-link {
background-color: #1584BC;
color: #fff;
border: 0;
border-radius: 30px;
margin-top: 12px;
min-height: 43px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.driver-login-link:hover { background-color: #1a99d6; color: #fff; }
.driver-login-link .icon { display: inline-flex; }
</style>
+2
View File
@@ -2141,6 +2141,8 @@
"auth": {
"country_code": "@:{'templates.generated.compat.global.phone_country_code'}",
"customer_login_title": "@:{'templates.generated.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.chauffør'}? @.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.her'} @:{'words.generated.for'} @:{'words.generated.at'} @:{'words.generated.fa'} @:{'words.generated.adgang'} @:{'words.generated.til'} @:{'words.generated.dine'} @:{'words.generated.vaske'}.",
"driver_login_button": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @.lower:{'words.generated.som'} @:{'words.generated.chauffør'}",
"driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Glemt @:{'words.generated.adgangskode'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} @:{'words.generated.digitale'} @:{'words.generated.løsninger'} @:{'words.generated.til'} @:{'words.generated.lastbilvask'}",
+3 -1
View File
@@ -2258,7 +2258,9 @@
"remember_me": "@.capitalize:{'words.generated.angemeldet'} @:{'words.generated.bleiben'}",
"terms_acceptance": "@.capitalize:{'words.generated.mit'} @:{'words.generated.klick'} @:{'words.generated.auf'} \"@.capitalize:{'words.generated.anmelden'}\" akzeptieren @.capitalize:{'words.generated.sie'} @:{'words.generated.die'} Datenschutzrichtlinie @:{'words.generated.und'} @:{'words.generated.die'} Nutzungsbedingungen @:{'words.generated.von'} @:{'words.generated.truckwash'}.@:{'words.generated.io'}",
"username": "@:{'words.generated.benutzername'}",
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}"
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
},
"book_wash": {
"title_desc": "@.capitalize:{'words.generated.buchen'} @.capitalize:{'words.generated.sie'} @:{'words.generated.eine'} @.upper:{'words.generated.w'}?@:{'words.generated.sche'} @:{'words.generated.in'} einer @:{'words.generated.unserer'} @:{'words.generated.abteilungen'} ? optional @:{'words.generated.mit'} Sicherheitsplombe.",
+2
View File
@@ -1972,6 +1972,8 @@
"auth": {
"country_code": "@.capitalize:{'words.generated.country'} @.capitalize:{'words.generated.code'}",
"customer_login_title": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.a'} @:{'words.generated.driver'}? @.capitalize:{'words.generated.log'} @:{'words.generated.in'} @:{'words.generated.here'} @:{'words.generated.to'} @:{'words.generated.register'} @:{'words.generated.a'} @:{'words.generated.wash'}.",
"driver_login_button": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Forgot @.capitalize:{'words.generated.password'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} - @:{'words.generated.digital'} @:{'words.generated.solutions'} @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.washing'}",
+2
View File
@@ -784,6 +784,8 @@
"country_code": "@:{'templates.generated.compat.auth.country_code'}",
"customer_login_title": "@:{'templates.generated.compat.auth.customer_login_title'}",
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'templates.generated.compat.auth.driver_login_button'}",
"driver_login_title": "@:{'templates.generated.compat.auth.driver_login_title'}",
"forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}",
"login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}",
+3 -1
View File
@@ -2261,7 +2261,9 @@
"remember_me": "Husk meg",
"terms_acceptance": "@.capitalize:{'words.generated.ved'} @:{'words.generated.a'} klikke \"@.capitalize:{'words.generated.logg'} @:{'words.generated.inn'}\" godtar @:{'words.generated.du'} @:{'words.generated.truckwash'}.@:{'words.generated.io'} personvernerklæring @:{'words.generated.og'} @:{'words.generated.vilkar'}",
"username": "@.capitalize:{'words.generated.brukernavn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}"
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
},
"book_wash": {
"title_desc": "Bestill @:{'words.generated.vask'} @:{'words.generated.pa'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.av'} våre @:{'words.generated.avdelinger'} - @:{'words.generated.med'} @:{'words.generated.mulighet'} @:{'words.generated.for'} @:{'words.generated.sikkerhetsplombering'}.",
+3 -1
View File
@@ -2311,7 +2311,9 @@
"remember_me": "Kom ihåg mig",
"terms_acceptance": "@.capitalize:{'words.generated.genom'} @:{'words.generated.att'} @:{'words.generated.klicka'} @:{'words.generated.pa'} \"@.capitalize:{'words.generated.logga'} @:{'words.generated.in'}\" accepterar @:{'words.generated.du'} @:{'words.generated.truckwash'}.@:{'words.generated.io'}:@:{'words.generated.s'} integritetspolicy @:{'words.generated.och'} @:{'words.generated.villkor'}",
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}"
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
},
"book_wash": {
"title_desc": "@.capitalize:{'words.generated.boka'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.tvatt'} @:{'words.generated.pa'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.av'} vara @:{'words.generated.avdelningar'} - @:{'words.generated.med'} @:{'words.generated.mojlighet'} @:{'words.generated.till'} säkerhetsfärsegling.",
@@ -3,6 +3,8 @@
"auth": {
"country_code": "@:{'phrases.compat.global.phone_country_code'}",
"customer_login_title": "@:{'phrases.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.chauffør'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @:{'terms.glossary.her'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.fa'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @.lower:{'terms.glossary.som'} @:{'terms.glossary.chauffør'}",
"driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Glemt @:{'terms.glossary.adgangskode'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} @:{'terms.glossary.digitale'} @:{'terms.glossary.løsninger'} @:{'terms.glossary.til'} @:{'terms.glossary.lastbilvask'}",
@@ -10,7 +10,9 @@
"remember_me": "@.capitalize:{'terms.glossary.angemeldet'} @:{'terms.glossary.bleiben'}",
"terms_acceptance": "@.capitalize:{'terms.glossary.mit'} @:{'terms.glossary.klick'} @:{'terms.glossary.auf'} \"@.capitalize:{'terms.glossary.anmelden'}\" akzeptieren @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.die'} Datenschutzrichtlinie @:{'terms.glossary.und'} @:{'terms.glossary.die'} Nutzungsbedingungen @:{'terms.glossary.von'} @:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'}",
"username": "@:{'terms.glossary.benutzername'}",
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}"
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
}
}
}
@@ -3,6 +3,8 @@
"auth": {
"country_code": "@.capitalize:{'terms.glossary.country'} @.capitalize:{'terms.glossary.code'}",
"customer_login_title": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.a'} @:{'terms.glossary.driver'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.in'} @:{'terms.glossary.here'} @:{'terms.glossary.to'} @:{'terms.glossary.register'} @:{'terms.glossary.a'} @:{'terms.glossary.wash'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} - @:{'terms.glossary.digital'} @:{'terms.glossary.solutions'} @:{'terms.glossary.for'} @:{'terms.glossary.truck'} @:{'terms.glossary.washing'}",
@@ -3,6 +3,8 @@
"country_code": "@:{'phrases.compat.auth.country_code'}",
"customer_login_title": "@:{'phrases.compat.auth.customer_login_title'}",
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}",
"driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}",
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
@@ -10,7 +10,9 @@
"remember_me": "Husk meg",
"terms_acceptance": "@.capitalize:{'terms.glossary.ved'} @:{'terms.glossary.a'} klikke \"@.capitalize:{'terms.glossary.logg'} @:{'terms.glossary.inn'}\" godtar @:{'terms.glossary.du'} @:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} personvernerklæring @:{'terms.glossary.og'} @:{'terms.glossary.vilkar'}",
"username": "@.capitalize:{'terms.glossary.brukernavn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}"
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
}
}
}
@@ -10,7 +10,9 @@
"remember_me": "Kom ihåg mig",
"terms_acceptance": "@.capitalize:{'terms.glossary.genom'} @:{'terms.glossary.att'} @:{'terms.glossary.klicka'} @:{'terms.glossary.pa'} \"@.capitalize:{'terms.glossary.logga'} @:{'terms.glossary.in'}\" accepterar @:{'terms.glossary.du'} @:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'}:@:{'terms.glossary.s'} integritetspolicy @:{'terms.glossary.och'} @:{'terms.glossary.villkor'}",
"username": "@.capitalize:{'terms.glossary.anvandarnamn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}"
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
}
}
}
+33
View File
@@ -65,6 +65,39 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
line-height: 1;
}
/* Tablet: tighter split + smaller sidebar title */
@media (max-width: 1024px) {
.login-section .wrapper .sidebar { width: 25%; padding: 50px 14px; }
.login-section .wrapper .main { width: 75%; padding: 50px 14px; }
.login-section .wrapper .sidebar h1 { font-size: 2.8em; }
}
/* Mobile: stack sidebar above main so it never forces a horizontal scroll,
and shrink the sidebar so it doesn't dominate the viewport. */
@media (max-width: 768px) {
.login-section .wrapper {
flex-direction: column;
min-height: 100vh;
}
.login-section .wrapper .sidebar {
width: 100%;
height: auto;
position: relative;
padding: 28px 16px;
min-height: 140px;
}
.login-section .wrapper .sidebar h1 {
font-size: 1.8em;
line-height: 1.1;
margin: 0;
}
.login-section .wrapper .main {
width: 100%;
padding: 28px 16px 60px;
}
.login-section .wrapper .main .logo { margin-bottom: 24px; }
}
.login-section .wrapper .sidebar p{
color: #fff;
font-size: 2em;
+54
View File
@@ -28,6 +28,21 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
<div class="main">
<LoginForm />
<p>Ved at klikke Log in accepterer du truckwash.io <router-link :to="{ name: 'privacy-policy' }">terms of service og privacy policy</router-link></p>
<!-- Driver discoverability: secondary entry so sub-users (drivers) don't
need the direct /login/driver URL on phones / tablets / desktops. -->
<div class="driver-entry" data-testid="landing-driver-entry">
<p class="driver-entry__intro">{{ $t('auth.driver_entry_intro') }}</p>
<router-link
:to="{name: 'subuserlogin'}"
class="submit driver-login-link"
id="landing-driver-login-button"
data-testid="landing-driver-login-link"
>
<span class="icon"><i class="fas fa-truck"></i></span>
<span>{{ $t('auth.driver_login_button') }}</span>
</router-link>
</div>
</div>
</div>
</section>
@@ -66,6 +81,45 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
}
/* end hero*/
/* Driver entry: secondary block below the customer login form, with a distinct
background tint so it doesn't compete with the primary customer login but
remains clearly reachable from any viewport. */
.driver-entry {
margin-top: 28px;
padding: 18px 22px;
background-color: #F2F8FC;
border: 1px solid #BFE0EF;
border-radius: 12px;
max-width: 420px;
width: 100%;
text-align: center;
}
.driver-entry__intro {
color: #13324C;
font-size: 14px;
margin: 0 0 10px 0;
}
.driver-entry .driver-login-link {
background-color: #1584BC;
color: #fff;
border: 0;
border-radius: 30px;
min-height: 43px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 20px;
text-decoration: none;
}
.driver-entry .driver-login-link:hover { background-color: #1a99d6; color: #fff; }
@media (max-width: 768px) {
.driver-entry { padding: 14px 16px; }
.driver-entry .driver-login-link { width: 100%; }
}
/* stary hero*/
.text-image-section{
padding: 70px 0;
+70
View File
@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";
/**
* E2E coverage for sub-user (driver) authentication discoverability.
*
* Background: drivers could only reach /login/driver via the direct URL. This
* suite verifies that:
* - /login shows a clearly-clickable driver login entry on every viewport.
* - / (landing) shows a driver login entry as a secondary block.
* - Clicking either entry navigates to /login/driver and the form is usable
* on mobile (form inputs reachable, submit button visible).
*/
test.describe("[AUTH][Driver] discoverability", () => {
test("/login surfaces the driver login button on desktop and mobile", async ({ page }, testInfo) => {
await page.goto("/login");
// The driver entry point is mounted by LoginForm.vue.
const driverLink = page.getByTestId("driver-login-link");
await expect(driverLink).toBeVisible();
// Sanity: it points at the driver login route.
await expect(driverLink).toHaveAttribute("href", /\/login\/driver/);
// Mobile viewport: the button is still in the viewport (no horizontal scroll,
// tappable height >= 36px).
if (testInfo.project.name.includes("mobile")) {
const box = await driverLink.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(36);
const viewport = page.viewportSize()!;
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width);
}
});
test("/ surfaces the driver entry block with a working button", async ({ page }) => {
await page.goto("/");
const entry = page.getByTestId("landing-driver-entry");
await expect(entry).toBeVisible();
const link = page.getByTestId("landing-driver-login-link");
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("href", /\/login\/driver/);
});
test("clicking the driver login entry navigates to /login/driver and the form is usable", async ({
page,
}, testInfo) => {
await page.goto("/login");
await page.getByTestId("driver-login-link").click();
await expect(page).toHaveURL(/\/login\/driver/);
// The driver form mounts the phone + username method selector; both inputs
// and the login button should be reachable in the viewport.
await expect(page.locator("#subuser_login_method_phone_button")).toBeVisible();
await expect(page.locator("#subuser_login_method_username_button")).toBeVisible();
await expect(page.locator("#subuser-login-button")).toBeVisible();
if (testInfo.project.name.includes("mobile")) {
// The login button must be in the viewport on mobile so a driver with one
// hand can tap it without scrolling.
const box = await page.locator("#subuser-login-button").boundingBox();
expect(box).not.toBeNull();
const viewport = page.viewportSize()!;
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height);
}
});
});