318 lines
8.1 KiB
Vue
318 lines
8.1 KiB
Vue
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import axios from 'axios'
|
|
import { API_URL } from "@/config.js";
|
|
import { parseError, getError, addError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
|
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.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);
|
|
|
|
onMounted(() => {
|
|
passkeySupported.value = isPasskeySupported();
|
|
});
|
|
|
|
// Login method selection
|
|
const loginMethod = ref('phone'); // 'phone' or 'username'
|
|
|
|
// Phone login fields
|
|
const phone_country_code = ref(45);
|
|
const phone = ref('');
|
|
|
|
|
|
// Username login fields
|
|
const username = ref('');
|
|
|
|
// Password (common)
|
|
const password = ref('');
|
|
|
|
// 2FA state
|
|
const requires2FA = ref(false);
|
|
const twoFactorToken = ref('');
|
|
|
|
const login = async () => {
|
|
clearErrors();
|
|
const passwordPolicyError = getSubuserPasswordPolicyError(password.value);
|
|
if (passwordPolicyError) {
|
|
addError(passwordPolicyError, 'auth');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let requestBody = { password: password.value };
|
|
|
|
if (loginMethod.value === 'phone') {
|
|
requestBody.phone_country_code = parseInt(phone_country_code.value);
|
|
requestBody.phone = parseInt(phone.value);
|
|
} else {
|
|
requestBody.username = username.value;
|
|
}
|
|
|
|
// Make direct API call to check for 2FA response
|
|
const response = await axios.post(API_URL + '/subusers/auth/password', requestBody);
|
|
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 session token
|
|
if (data.session) {
|
|
clearEdgeGatewayWorkspaceCache();
|
|
localStorage.setItem('token', data.session);
|
|
localStorage.setItem('is_subuser', 'true');
|
|
window.location.reload();
|
|
}
|
|
} catch (e) {
|
|
parseError(e, 'auth');
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const on2FACancel = () => {
|
|
requires2FA.value = false;
|
|
twoFactorToken.value = '';
|
|
password.value = '';
|
|
};
|
|
|
|
const doNothing = () => {
|
|
// Prevent form submission
|
|
};
|
|
|
|
// Passkey login
|
|
const loginWithPasskey = async () => {
|
|
isPasskeyLoading.value = true;
|
|
try {
|
|
const result = await authenticateWithPasskey('subuser');
|
|
// Subuser login returns 'session' token, user login returns 'token'
|
|
const sessionToken = result.session || result.token;
|
|
if (sessionToken) {
|
|
clearEdgeGatewayWorkspaceCache();
|
|
localStorage.setItem('token', sessionToken);
|
|
localStorage.setItem('is_subuser', 'true');
|
|
// Reload the page to update the UI
|
|
window.location.reload();
|
|
}
|
|
} catch (e) {
|
|
addError('auth', e.message || t('passkeys.login_failed'));
|
|
} finally {
|
|
isPasskeyLoading.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<!-- 2FA Verification -->
|
|
<TwoFactorVerify
|
|
v-if="requires2FA"
|
|
:two-factor-token="twoFactorToken"
|
|
user-type="subuser"
|
|
@cancel="on2FACancel"
|
|
/>
|
|
|
|
<!-- Normal Login Form -->
|
|
<form v-else @submit.prevent="doNothing">
|
|
<!-- Login method selector -->
|
|
<div class="form-field">
|
|
<label class="label">{{ t('auth.login_with') }}</label>
|
|
<div class="login-method-selector" id="subuser_login_method_selector">
|
|
<button
|
|
type="button"
|
|
class="method-btn"
|
|
:class="{ active: loginMethod === 'phone' }"
|
|
@click="loginMethod = 'phone'"
|
|
id="subuser_login_method_phone_button"
|
|
>
|
|
{{ t('common.phone') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="method-btn"
|
|
:class="{ active: loginMethod === 'username' }"
|
|
@click="loginMethod = 'username'"
|
|
id="subuser_login_method_username_button"
|
|
>
|
|
{{ t('auth.username') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phone login fields -->
|
|
<template v-if="loginMethod === 'phone'">
|
|
<div class="columns is-mobile">
|
|
<div class="column is-narrow">
|
|
<div class="form-field">
|
|
<label class="label">{{ t('auth.country_code') }}</label>
|
|
<input type="text" v-model="phone_country_code" placeholder="45" pattern="[1-9][0-9]*" inputmode="numeric" @focusout="phone_country_code = phone_country_code.replace(/\D/g, '')" class="input-country-code" name="phone_country_code"/>
|
|
</div>
|
|
</div>
|
|
<div class="column">
|
|
<div class="form-field">
|
|
<label class="label">{{ t('auth.phone_number') }}</label>
|
|
<input type="tel" v-model="phone" placeholder="12345678" class="input-phone-number" name="phone" pattern="[0-9]*" inputmode="numeric" @focusout="phone = phone.replace(/\D/g, '')" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Username login fields -->
|
|
<template v-else>
|
|
<div class="form-field">
|
|
<label class="label">{{ t('auth.username') }}</label>
|
|
<input type="text" v-model="username" :placeholder="t('auth.your_username')" name="username" />
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Password field -->
|
|
<div class="form-field">
|
|
<label class="label">{{ t('auth.password') }}</label>
|
|
<input type="password" v-model="password" name="password" />
|
|
</div>
|
|
|
|
<!-- Display the error -->
|
|
<div class="field" v-if="getError('auth')">
|
|
<p class="help is-danger" id="subuser_auth_alert_error">
|
|
{{ getError('auth') }}
|
|
</p>
|
|
</div>
|
|
|
|
<LoadButtonWhileAwait :loadFunction="login" class="submit" id="subuser-login-button">
|
|
{{ t('auth.login') }}
|
|
</LoadButtonWhileAwait>
|
|
|
|
<!-- Passkey login button -->
|
|
<button
|
|
v-if="passkeySupported"
|
|
type="button"
|
|
class="submit passkey-btn"
|
|
@click="loginWithPasskey"
|
|
:disabled="isPasskeyLoading"
|
|
id="subuser-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>
|
|
|
|
<router-link :to="{name: 'login'}" class="link">{{ t('auth.login_as_customer') }}</router-link>
|
|
</form>
|
|
</template>
|
|
|
|
<style scoped>
|
|
input.input-country-code {
|
|
width: 100px;
|
|
}
|
|
input.input-phone-number {
|
|
width: 100%;
|
|
}
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
max-width: 400px;
|
|
margin-bottom: 40px;
|
|
width: 100%;
|
|
}
|
|
|
|
.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;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
form input:not([type="checkbox"]) {
|
|
min-height: 40px;
|
|
}
|
|
|
|
.login-method-selector {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.method-btn {
|
|
flex: 1;
|
|
padding: 10px 16px;
|
|
border: 1.5px solid #333;
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
color: #13324C;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.method-btn:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
.method-btn.active {
|
|
background: #13324C;
|
|
color: #fff;
|
|
border-color: #13324C;
|
|
}
|
|
|
|
.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;
|
|
margin-top: 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.submit:hover {
|
|
background-color: #1a4263;
|
|
}
|
|
|
|
.link {
|
|
text-align: center;
|
|
color: #13324C;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.help.is-danger {
|
|
color: #f14668;
|
|
font-size: 14px;
|
|
}
|
|
</style>
|