## 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>
315 lines
8.7 KiB
Vue
315 lines
8.7 KiB
Vue
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
import axios from 'axios'
|
|
import {API_URL} from "@/config.js";
|
|
import { parseError, clearErrors, addError, getError } from "@/components/request/HandleGlobalError.vue";
|
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
|
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
|
|
|
const { t } = useI18n();
|
|
|
|
// Passkey support
|
|
const passkeySupported = ref(false);
|
|
const isPasskeyLoading = ref(false);
|
|
|
|
// 2FA state
|
|
const requires2FA = ref(false);
|
|
const twoFactorToken = ref('');
|
|
|
|
onMounted(() => {
|
|
passkeySupported.value = isPasskeySupported();
|
|
});
|
|
|
|
const customer_number = ref();
|
|
const password = ref('');
|
|
|
|
const successMessage = ref('');
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
const resolveLoginRedirectPath = () => {
|
|
const redirectQuery = router.currentRoute.value.query.redirect;
|
|
const redirectValue = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery;
|
|
if (!redirectValue) {
|
|
return "/redirect";
|
|
}
|
|
|
|
try {
|
|
const redirectUrl = new URL(redirectValue, window.location.origin);
|
|
if (redirectUrl.origin !== window.location.origin) {
|
|
return "/redirect";
|
|
}
|
|
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
|
|
} catch {
|
|
return "/redirect";
|
|
}
|
|
};
|
|
|
|
const login = async () => {
|
|
try {
|
|
const response = await axios.post(API_URL + '/auth/login', {
|
|
customer_number: customer_number.value,
|
|
password: password.value,
|
|
g_recaptcha_response: reCAPTCHA_data.value.enabled ? grecaptcha.getResponse() : null,
|
|
});
|
|
|
|
const data = response.data.data || response.data;
|
|
|
|
// Check if 2FA is required
|
|
if (data['2fa_required'] || data.two_fa_required) {
|
|
requires2FA.value = true;
|
|
twoFactorToken.value = data['2fa_token'] || data.two_fa_token;
|
|
return;
|
|
}
|
|
|
|
// Save the token in the local storage
|
|
clearEdgeGatewayWorkspaceCache();
|
|
localStorage.setItem('token', data.token);
|
|
// Set the success message
|
|
successMessage.value = "Du er nu logget ind!"
|
|
// Wait 500ms before reloading the page to show the success message
|
|
setTimeout(() => {
|
|
window.location.assign(resolveLoginRedirectPath());
|
|
}, 500);
|
|
// window.location.reload();
|
|
} catch (e) {
|
|
parseError(e, 'auth');
|
|
}
|
|
};
|
|
|
|
const on2FACancel = () => {
|
|
requires2FA.value = false;
|
|
twoFactorToken.value = '';
|
|
password.value = '';
|
|
};
|
|
|
|
const doNothing = () => {
|
|
// Do nothing
|
|
};
|
|
|
|
// Passkey login
|
|
const loginWithPasskey = async () => {
|
|
isPasskeyLoading.value = true;
|
|
clearErrors();
|
|
try {
|
|
// Parse customer_number as integer - API expects integer type
|
|
const customerNum = customer_number.value ? parseInt(customer_number.value, 10) : null;
|
|
// Get recaptcha token if enabled
|
|
const recaptchaToken = reCAPTCHA_data.value.enabled ? grecaptcha.getResponse() : null;
|
|
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
|
console.log('Passkey authentication result:', result);
|
|
if (result.token) {
|
|
clearEdgeGatewayWorkspaceCache();
|
|
localStorage.setItem('token', result.token);
|
|
successMessage.value = "Du er nu logget ind!";
|
|
setTimeout(() => {
|
|
router.go(0);
|
|
}, 500);
|
|
} else {
|
|
// No token returned - this shouldn't happen for successful auth
|
|
addError('auth', t('passkeys.login_failed'));
|
|
}
|
|
} catch (e) {
|
|
console.error('Passkey login error:', e);
|
|
// Check if it's an axios error with response data
|
|
if (e.response?.data?.message) {
|
|
addError('auth', e.response.data.message);
|
|
} else if (e.response?.data?.error) {
|
|
addError('auth', e.response.data.error);
|
|
} else {
|
|
addError('auth', e.message || t('passkeys.login_failed'));
|
|
}
|
|
} finally {
|
|
isPasskeyLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const reCAPTCHA_data = ref({
|
|
enabled: false,
|
|
site_key: '',
|
|
});
|
|
|
|
const ratelimit_data = ref({
|
|
enabled: false,
|
|
limit: 0,
|
|
remaining: 0,
|
|
reset: 0,
|
|
warning: null,
|
|
});
|
|
|
|
onMounted(async () => {
|
|
const response = await axios.get(`${API_URL}/auth/reCAPTCHA/public`);
|
|
const data = response.data.data;
|
|
console.log(data);
|
|
reCAPTCHA_data.value = data.recaptcha;
|
|
ratelimit_data.value = data.rate_limit;
|
|
// If the reCAPTCHA is enabled, load the reCAPTCHA script
|
|
if (reCAPTCHA_data.value.enabled) {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://www.google.com/recaptcha/api.js';
|
|
script.async = true;
|
|
script.defer = true;
|
|
document.head.appendChild(script);
|
|
}
|
|
});
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<!-- 2FA Verification -->
|
|
<TwoFactorVerify
|
|
v-if="requires2FA"
|
|
:two-factor-token="twoFactorToken"
|
|
user-type="user"
|
|
@cancel="on2FACancel"
|
|
/>
|
|
|
|
<!-- Normal Login Form -->
|
|
<form v-else @submit.prevent="doNothing">
|
|
<div class="form-field">
|
|
<label class="label">{{ $t('auth.customer_number') }}</label>
|
|
<input type="text" v-model="customer_number" name="customer_number" data-testid="login-customer-number" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="label">{{ $t('auth.password') }}</label>
|
|
<input type="password" v-model="password" name="password" data-testid="login-password" />
|
|
</div>
|
|
<!-- reCaptcha, if enabled -->
|
|
<div class="field mt-6" v-if="reCAPTCHA_data.enabled">
|
|
<div class="control">
|
|
<div class="g-recaptcha" :data-sitekey="reCAPTCHA_data.site_key"></div>
|
|
</div>
|
|
</div>
|
|
<!-- Display the error -->
|
|
<div class="field" v-if="getError('auth')">
|
|
<p class="help is-danger" id="user_auth_alert_error">
|
|
{{ getError('auth') }}
|
|
</p>
|
|
</div>
|
|
<div class="field" v-if="successMessage">
|
|
<!-- Success message for login -->
|
|
<p class="help is-success" id="user_auth_alert_success">
|
|
{{ successMessage }}
|
|
</p>
|
|
</div>
|
|
<div class="form-field">
|
|
<label><input type="checkbox" id="remember" name="remember">{{ $t('auth.remember_me') }}</label>
|
|
</div>
|
|
|
|
<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
|
|
v-if="passkeySupported"
|
|
type="button"
|
|
class="submit passkey-btn"
|
|
@click="loginWithPasskey"
|
|
:disabled="isPasskeyLoading"
|
|
id="passkey-login-button"
|
|
>
|
|
<span class="icon" v-if="!isPasskeyLoading">
|
|
<i class="fas fa-fingerprint"></i>
|
|
</span>
|
|
<span class="icon" v-else>
|
|
<i class="fas fa-spinner fa-spin"></i>
|
|
</span>
|
|
<span>{{ $t('passkeys.login_with_passkey') }}</span>
|
|
</button>
|
|
</form>
|
|
</template>
|
|
<style scoped>
|
|
form{
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
max-width: 400px;
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.form-field{
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.form-field label{
|
|
color: #13324C;
|
|
font-size: 16px;
|
|
line-height: 1;
|
|
display: flex;
|
|
gap: 16px;
|
|
}
|
|
|
|
form input{
|
|
border: 1.5px solid #333;
|
|
border-radius: 8px;
|
|
margin-top: 4px;
|
|
font-size: 16px;
|
|
line-height: 1;
|
|
}
|
|
|
|
form input:not([type="checkbox"]) {
|
|
min-height: 40px;
|
|
}
|
|
|
|
.submit {
|
|
width: 100%;
|
|
border-radius: 30px;
|
|
background-color: #13324C;
|
|
border: 0;
|
|
color: #fff;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 16px;
|
|
min-height: 43px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.submit:hover {
|
|
background-color: #1a4263;
|
|
}
|
|
|
|
.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>
|