Add passkey support
This commit is contained in:
@@ -12,6 +12,7 @@ const dialog = useTemplateRef('dialog');
|
||||
const formError = ref({});
|
||||
const busy = ref (false);
|
||||
const password = ref('');
|
||||
const twoFAMethod = ref('totp'); // 'totp' or 'passkey'
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
@@ -25,10 +26,19 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
|
||||
const [error] = await profileModel.disableTwoFA(password.value);
|
||||
let error;
|
||||
if (twoFAMethod.value === 'passkey') {
|
||||
[error] = await profileModel.deletePasskey(password.value);
|
||||
} else {
|
||||
[error] = await profileModel.disableTwoFA(password.value);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error.status === 412) formError.value.password = error.body.message;
|
||||
else {
|
||||
if (error.status === 412) {
|
||||
password.value = '';
|
||||
formError.value.password = error.body.message;
|
||||
setTimeout(() => document.getElementById('passwordInput')?.focus(), 0);
|
||||
} else {
|
||||
formError.value.generic = error.status ? error.body.message : 'Internal error';
|
||||
console.error('Failed to disable 2fa', error);
|
||||
}
|
||||
@@ -46,7 +56,8 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
async open(method = 'totp') {
|
||||
twoFAMethod.value = method;
|
||||
password.value = '';
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
@@ -64,7 +75,7 @@ defineExpose({
|
||||
:confirm-label="$t('profile.disable2FA.disable')"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
confirm-style="primary"
|
||||
confirm-style="danger"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@@ -78,7 +89,7 @@ defineExpose({
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.disable2FA.password') }}</label>
|
||||
<PasswordInput v-model="password" required />
|
||||
<PasswordInput v-model="password" required id="passwordInput" />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -234,6 +234,50 @@ function create() {
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async getPasskey() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/profile/passkey`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.passkey];
|
||||
},
|
||||
async getPasskeyRegistrationOptions() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register/options`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async registerPasskey(credential, name) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register`, { credential, name }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async deletePasskey(password) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/disable`, { password }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button, PasswordInput, TextInput, fetcher, FormGroup } from '@cloudron/pankow';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import PublicPageLayout from '../components/PublicPageLayout.vue';
|
||||
|
||||
const ready = ref(false);
|
||||
const busy = ref(false);
|
||||
const passwordError = ref(null);
|
||||
const totpError = ref(null);
|
||||
const passkeyError = ref(null);
|
||||
const internalError = ref(null);
|
||||
const oidcError = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const totpToken = ref('');
|
||||
const totpTokenRequired = ref(false);
|
||||
const passkeyOptions = ref(null);
|
||||
const twoFARequired = ref(false);
|
||||
|
||||
// coming from oidc_login.html template
|
||||
const name = window.cloudron.name;
|
||||
@@ -26,6 +29,7 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
passwordError.value = false;
|
||||
totpError.value = false;
|
||||
passkeyError.value = false;
|
||||
internalError.value = false;
|
||||
oidcError.value = '';
|
||||
|
||||
@@ -41,20 +45,32 @@ async function onSubmit() {
|
||||
// the oidc login session is old
|
||||
window.location.reload();
|
||||
} else if (res.status === 401) {
|
||||
if (res.body.message.indexOf('totpToken') !== -1) {
|
||||
totpError.value = totpTokenRequired.value; // only set on second try coming from login
|
||||
totpTokenRequired.value = true;
|
||||
totpToken.value = '';
|
||||
setTimeout(() => document.getElementById('inputTotpToken').focus(), 0);
|
||||
// Authentication failed (wrong password, invalid TOTP, or invalid passkey)
|
||||
if (res.body.message && res.body.message.indexOf('passkey') !== -1) {
|
||||
passkeyError.value = true;
|
||||
} else if (res.body.message && res.body.message.indexOf('totpToken') !== -1) {
|
||||
totpError.value = true;
|
||||
} else {
|
||||
password.value = '';
|
||||
passwordError.value = true;
|
||||
setTimeout(() => document.getElementById('inputPassword').focus(), 0);
|
||||
setTimeout(() => document.getElementById('inputPassword')?.focus(), 0);
|
||||
}
|
||||
} else if (res.status === 200) {
|
||||
// Check if 2FA is required
|
||||
if (res.body.twoFactorRequired) {
|
||||
twoFARequired.value = true;
|
||||
passkeyOptions.value = res.body.passkeyOptions || null;
|
||||
totpToken.value = '';
|
||||
|
||||
// If only passkeys are available (no TOTP), auto-trigger passkey flow
|
||||
if (passkeyOptions.value) await onUsePasskey();
|
||||
else setTimeout(() => document.getElementById('inputTotpToken')?.focus(), 0);
|
||||
} else if (res.body.redirectTo) {
|
||||
return window.location.href = res.body.redirectTo;
|
||||
} else {
|
||||
console.error('login success but missing redirectTo in data:', res.body);
|
||||
internalError.value = true;
|
||||
}
|
||||
} else if (res.status === 200 ) {
|
||||
if (res.body.redirectTo) return window.location.href = res.body.redirectTo;
|
||||
console.error('login success but missing redirectTo in data:', res.body);
|
||||
internalError.value = true;
|
||||
} else if (res.status >= 400 && res.status < 500) {
|
||||
oidcError.value = 'OpenID Error: ' + (res.body.message || '') + '. Will reload in 5 seconds';
|
||||
setTimeout(() => window.location.href = '/', 5000);
|
||||
@@ -69,6 +85,43 @@ async function onSubmit() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onUsePasskey() {
|
||||
if (!passkeyOptions.value) return;
|
||||
|
||||
busy.value = true;
|
||||
passkeyError.value = false;
|
||||
totpError.value = false;
|
||||
internalError.value = false;
|
||||
|
||||
try {
|
||||
// Start WebAuthn authentication ceremony
|
||||
const credential = await startAuthentication({ optionsJSON: passkeyOptions.value });
|
||||
|
||||
// Submit with passkey response
|
||||
const body = {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
passkeyResponse: credential
|
||||
};
|
||||
|
||||
const res = await fetcher.post(submitUrl, body);
|
||||
|
||||
if (res.status === 200 && res.body.redirectTo) {
|
||||
window.location.href = res.body.redirectTo;
|
||||
return;
|
||||
} else if (res.status === 401) {
|
||||
passkeyError.value = true;
|
||||
} else {
|
||||
internalError.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Passkey authentication failed', error);
|
||||
passkeyError.value = true;
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// placed optionally in local storage by setupaccount.js
|
||||
const autoLoginToken = localStorage.cloudronFirstTimeToken;
|
||||
@@ -94,7 +147,7 @@ onMounted(async () => {
|
||||
<h2>{{ $t('login.loginAction') }}</h2>
|
||||
<p v-html="note" style="margin-bottom: 8px"></p>
|
||||
|
||||
<form @submit.prevent="onSubmit" v-if="!totpTokenRequired">
|
||||
<form @submit.prevent="onSubmit" v-if="!twoFARequired">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
@@ -119,21 +172,29 @@ onMounted(async () => {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form @submit.prevent="onSubmit" v-if="totpTokenRequired" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit" v-if="twoFARequired" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<FormGroup :has-error="totpError">
|
||||
<label for="inputTotpToken">{{ $t('login.2faToken') }}</label>
|
||||
<TextInput id="inputTotpToken" v-model="totpToken" required/>
|
||||
</FormGroup>
|
||||
<!-- Passkey or TOTP option -->
|
||||
<div v-if="passkeyOptions">
|
||||
<Button id="passkeyButton" @click="onUsePasskey" :disabled="busy" :loading="busy">{{ $t('login.usePasskeyAction') }}</Button>
|
||||
<div class="error-label" v-if="passkeyError">{{ $t('login.errorPasskeyFailed') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="error-label" v-if="totpError">{{ $t('login.errorIncorrect2FAToken') }}</div>
|
||||
<div v-else>
|
||||
<FormGroup :has-error="totpError">
|
||||
<label for="inputTotpToken">{{ $t('login.2faToken') }}</label>
|
||||
<TextInput id="inputTotpToken" v-model="totpToken"/>
|
||||
</FormGroup>
|
||||
|
||||
<div class="error-label" v-if="totpError">{{ $t('login.errorIncorrect2FAToken') }}</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken" :loading="busy">{{ $t('login.loginAction') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="actions">
|
||||
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken" :loading="busy">{{ $t('login.loginAction') }}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PublicPageLayout>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, inject } from 'vue';
|
||||
import { Button, SingleSelect, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
|
||||
import { ref, computed, onMounted, useTemplateRef, inject } from 'vue';
|
||||
import { Button, ButtonGroup, SingleSelect, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { setLanguage } from '../i18n.js';
|
||||
import { TOKEN_TYPES } from '../constants.js';
|
||||
import AppPasswords from '../components/AppPasswords.vue';
|
||||
@@ -99,24 +100,36 @@ async function onRevokeAllWebAndCliTokens() {
|
||||
await profileModel.logout();
|
||||
}
|
||||
|
||||
|
||||
// 2fa
|
||||
const userPasskey = ref(null);
|
||||
const twoFASetupMode = ref(''); // 'totp' or 'passkey'
|
||||
const twoFASecret = ref('');
|
||||
const twoFATotpToken = ref('');
|
||||
const twoFAQRCode = ref('');
|
||||
const twoFAEnableError = ref('');
|
||||
const passkeyRegisterError = ref('');
|
||||
const passkeyRegisterBusy = ref(false);
|
||||
const twoFADialog = useTemplateRef('twoFADialog');
|
||||
const has2FA = computed(() => profile.value.twoFactorAuthenticationEnabled || !!userPasskey.value);
|
||||
|
||||
async function loadPasskey() {
|
||||
const [error, result] = await profileModel.getPasskey();
|
||||
if (error) return console.error('Failed to load passkey', error);
|
||||
userPasskey.value = result;
|
||||
}
|
||||
|
||||
async function onOpenTwoFASetupDialog() {
|
||||
twoFASetupMode.value = 'passkey';
|
||||
twoFAEnableError.value = '';
|
||||
twoFATotpToken.value = '';
|
||||
passkeyRegisterError.value = '';
|
||||
|
||||
twoFADialog.value.open();
|
||||
|
||||
const [error, result] = await profileModel.setTwoFASecret();
|
||||
if (error) return console.error(error);
|
||||
|
||||
twoFAEnableError.value = '';
|
||||
twoFATotpToken.value = '';
|
||||
twoFASecret.value = result.secret;
|
||||
twoFAQRCode.value = result.qrcode;
|
||||
|
||||
twoFADialog.value.open();
|
||||
}
|
||||
|
||||
async function onTwoFAEnable() {
|
||||
@@ -127,12 +140,51 @@ async function onTwoFAEnable() {
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
|
||||
twoFADialog.value.close();
|
||||
}
|
||||
|
||||
async function onRegisterPasskey() {
|
||||
passkeyRegisterBusy.value = true;
|
||||
passkeyRegisterError.value = '';
|
||||
|
||||
try {
|
||||
// Get registration options from server
|
||||
const [optionsError, options] = await profileModel.getPasskeyRegistrationOptions();
|
||||
if (optionsError) {
|
||||
passkeyRegisterError.value = optionsError.body?.message || 'Failed to get registration options';
|
||||
passkeyRegisterBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Start WebAuthn registration ceremony
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
|
||||
// Send credential to server
|
||||
const [registerError] = await profileModel.registerPasskey(credential, 'Cloudron');
|
||||
if (registerError) {
|
||||
passkeyRegisterError.value = registerError.body?.message || 'Failed to register passkey';
|
||||
passkeyRegisterBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPasskey();
|
||||
await refreshProfile();
|
||||
twoFADialog.value.close();
|
||||
} catch (error) {
|
||||
passkeyRegisterError.value = error.message || 'Passkey registration failed';
|
||||
}
|
||||
|
||||
passkeyRegisterBusy.value = false;
|
||||
}
|
||||
|
||||
async function onTwoFADisable() {
|
||||
disableTwoFADialog.value.open();
|
||||
// Pass the current 2FA method to the disable dialog
|
||||
disableTwoFADialog.value.open(userPasskey.value ? 'passkey' : 'totp');
|
||||
}
|
||||
|
||||
async function onTwoFADisableSuccess() {
|
||||
await refreshProfile();
|
||||
await loadPasskey();
|
||||
}
|
||||
|
||||
// Init
|
||||
@@ -172,6 +224,9 @@ onMounted(async () => {
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) {
|
||||
onOpenTwoFASetupDialog();
|
||||
}
|
||||
|
||||
// Load passkey
|
||||
await loadPasskey();
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -181,29 +236,44 @@ onMounted(async () => {
|
||||
<PrimaryEmailDialog ref="primaryEmailDialog" @success="refreshProfile"/>
|
||||
<FallbackEmailDialog ref="fallbackEmailDialog" @success="refreshProfile"/>
|
||||
<PasswordChangeDialog ref="passwordChangeDialog" @success="refreshProfile"/>
|
||||
<DisableTwoFADialog ref="disableTwoFADialog" @success="refreshProfile"/>
|
||||
<DisableTwoFADialog ref="disableTwoFADialog" @success="onTwoFADisableSuccess"/>
|
||||
|
||||
<Dialog ref="twoFADialog" :title="$t('profile.enable2FA.title')" :dismissable="!config.mandatory2FA || profile.twoFactorAuthenticationEnabled">
|
||||
<div>
|
||||
<p class="text-warning" v-if="config.mandatory2FA && !profile.twoFactorAuthenticationEnabled">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
|
||||
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
<div style="text-align: center;">
|
||||
<img :src="twoFAQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
|
||||
<small>{{ twoFASecret }}</small>
|
||||
<!-- 2FA setup Dialog -->
|
||||
<Dialog ref="twoFADialog" :title="$t('profile.enable2FA.title')" :dismissable="!config.mandatory2FA || has2FA">
|
||||
<div style="text-align: center;">
|
||||
<p class="text-warning" v-if="config.mandatory2FA && !has2FA">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button secondary @click="twoFASetupMode = 'passkey'" :outline="twoFASetupMode !== 'passkey' || null">{{ $t('profile.enable2FA.passkeyOption') }}</Button>
|
||||
<Button secondary @click="twoFASetupMode = 'totp'" :outline="twoFASetupMode !== 'totp' || null">{{ $t('profile.enable2FA.totpOption') }}</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- Passkey Setup -->
|
||||
<div v-if="twoFASetupMode === 'passkey'" style="text-align: center; margin-top: 20px;">
|
||||
<Button @click="onRegisterPasskey()" :loading="passkeyRegisterBusy" :disabled="passkeyRegisterBusy">{{ $t('profile.enable2FA.registerPasskey') }}</Button>
|
||||
<div class="error-label" v-if="passkeyRegisterError">{{ passkeyRegisterError }}</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Setup -->
|
||||
<div v-if="twoFASetupMode === 'totp'">
|
||||
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
<div style="text-align: center;">
|
||||
<img :src="twoFAQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
|
||||
<small>{{ twoFASecret }}</small>
|
||||
</div>
|
||||
<br/>
|
||||
<form @submit.prevent="onTwoFAEnable()">
|
||||
<input type="submit" style="display: none;" :disabled="!twoFATotpToken"/>
|
||||
<FormGroup style="text-align: left;">
|
||||
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="twoFATotpToken" id="totpTokenInput" style="flex-grow: 1;"/>
|
||||
<Button @click="onTwoFAEnable()" :disabled="!twoFATotpToken">{{ $t('profile.enable2FA.enable') }}</Button>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-if="twoFAEnableError">{{ twoFAEnableError }}</div>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
<form @submit.prevent="onTwoFAEnable()">
|
||||
<input type="submit" style="display: none;" :disabled="!twoFATotpToken"/>
|
||||
<FormGroup style="text-align: left;">
|
||||
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="twoFATotpToken" id="totpTokenInput" style="flex-grow: 1;"/>
|
||||
<Button @click="onTwoFAEnable()" :disabled="!twoFATotpToken">{{ $t('profile.enable2FA.enable') }}</Button>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-if="twoFAEnableError">{{ twoFAEnableError }}</div>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -218,6 +288,9 @@ onMounted(async () => {
|
||||
<label>{{ $t('main.username') }}</label>
|
||||
<div>{{ profile.username }}</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button plain v-if="!profile.source" @click="onPasswordChange()">{{ $t('profile.changePasswordAction') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
@@ -253,10 +326,17 @@ onMounted(async () => {
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<Button tool v-if="!profile.source" @click="onPasswordChange()">{{ $t('profile.changePasswordAction') }}</Button>
|
||||
<Button tool v-if="!profile.source || !config.external2FA" @click="profile.twoFactorAuthenticationEnabled ? onTwoFADisable() : onOpenTwoFASetupDialog()">{{ $t(profile.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
|
||||
</div>
|
||||
<SettingsItem v-if="!profile.source || !config.external2FA">
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.twoFactorAuth.title') }}</label>
|
||||
<div v-if="!has2FA">{{ $t('profile.twoFactorAuth.disabled') }}</div>
|
||||
<div v-else-if="profile.twoFactorAuthenticationEnabled">{{ $t('profile.twoFactorAuth.totpEnabled') }} <i class="fa-solid fa-check text-success"></i></div>
|
||||
<div v-else-if="userPasskey">{{ $t('profile.twoFactorAuth.passkeyEnabled') }} <i class="fa-solid fa-check text-success"></i></div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button tool plain @click="has2FA ? onTwoFADisable() : onOpenTwoFASetupDialog()">{{ $t(has2FA ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user