feat: Refine 2fa (#11766)

* wip

* Update 2fa.qrdialog.vue

* Update 2fa.vue

* Update CHANGELOG.md

* tweak

* ✌️
This commit is contained in:
syuilo
2023-08-28 18:25:31 +09:00
committed by GitHub
parent 39d9172a2f
commit 257c4fccf1
28 changed files with 267 additions and 99 deletions

View File

@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required>
<MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
<template #label>{{ i18n.ts.token }}</template>
<template #prefix><i class="ti ti-123"></i></template>
</MkInput>

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div :class="$style.editor" class="_panel">
<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :lineNumbers="false"/>
<PrismEditor v-model="code" class="_monospace" :class="$style.code" :highlight="highlighter" :lineNumbers="false"/>
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
</div>
@@ -175,6 +175,14 @@ definePageMetadata({
position: relative;
}
.code {
background: #2d2d2d;
color: #ccc;
font-size: 14px;
line-height: 1.5;
padding: 5px;
}
.ui {
padding: 32px;
}

View File

@@ -4,45 +4,110 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal
ref="dialogEl"
:preferType="'dialog'"
:zPriority="'low'"
@click="cancel"
<MkModalWindow
ref="dialog"
:width="500"
:height="550"
@close="cancel"
@closed="emit('closed')"
>
<div :class="$style.root" class="_gaps_m">
<I18n :src="i18n.ts._2fa.step1" tag="div">
<template #a>
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
<template #header>{{ i18n.ts.setupOf2fa }}</template>
<div style="overflow-x: clip;">
<Transition
mode="out-in"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<template v-if="page === 0">
<div style="height: 100cqh; overflow: auto; text-align: center;">
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps">
<I18n :src="i18n.ts._2fa.step1" tag="div">
<template #a>
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
</template>
<template #b>
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
</template>
</I18n>
<div>{{ i18n.ts._2fa.step2 }}<br>{{ i18n.ts._2fa.step2Click }}</div>
<a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
<MkKeyValue :copy="twoFactorData.url">
<template #key>{{ i18n.ts._2fa.step2Uri }}</template>
<template #value>{{ twoFactorData.url }}</template>
</MkKeyValue>
</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</MkSpacer>
</div>
</template>
<template #b>
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
<template v-else-if="page === 1">
<div style="height: 100cqh; overflow: auto;">
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps">
<div>{{ i18n.ts._2fa.step3Title }}</div>
<MkInput v-model="token" autocomplete="one-time-code" type="number"></MkInput>
<div>{{ i18n.ts._2fa.step3 }}</div>
</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate @click="tokenDone">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</MkSpacer>
</div>
</template>
</I18n>
<div>
{{ i18n.ts._2fa.step2 }}<br>
{{ i18n.ts._2fa.step2Click }}
</div>
<a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
<MkKeyValue :copy="twoFactorData.url">
<template #key>{{ i18n.ts._2fa.step2Url }}</template>
<template #value>{{ twoFactorData.url }}</template>
</MkKeyValue>
<div class="_buttons">
<MkButton primary @click="ok">{{ i18n.ts.next }}</MkButton>
<MkButton @click="cancel">{{ i18n.ts.cancel }}</MkButton>
</div>
<template v-else-if="page === 2">
<div style="height: 100cqh; overflow: auto;">
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps">
<div style="text-align: center;">{{ i18n.ts._2fa.setupCompleted }}🎉</div>
<div style="text-align: center;">{{ i18n.ts._2fa.step4 }}</div>
<div style="text-align: center; font-weight: bold;">{{ i18n.ts._2fa.checkBackupCodesBeforeCloseThisWizard }}</div>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts._2fa.backupCodes }}</template>
<div class="_gaps">
<MkInfo warn>{{ i18n.ts._2fa.backupCodesDescription }}</MkInfo>
<div v-for="(code, i) in backupCodes" :key="code" class="_gaps_s">
<MkKeyValue :copy="code">
<template #key>#{{ i + 1 }}</template>
<template #value><code class="_monospace">{{ code }}</code></template>
</MkKeyValue>
</div>
</div>
</MkFolder>
</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton primary rounded gradate @click="allDone">{{ i18n.ts.done }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
</Transition>
</div>
</MkModal>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef, ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkModal from '@/components/MkModal.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkInput from '@/components/MkInput.vue';
import { i18n } from '@/i18n';
import * as os from '@/os';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import { confetti } from '@/scripts/confetti';
defineProps<{
twoFactorData: {
@@ -52,36 +117,53 @@ defineProps<{
}>();
const emit = defineEmits<{
(ev: 'ok'): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const cancel = () => {
emit('cancel');
emit('closed');
};
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const page = ref(0);
const token = ref<string | number | null>(null);
const backupCodes = ref<string[]>();
const ok = () => {
emit('ok');
emit('closed');
};
function cancel() {
dialog.value.close();
}
async function tokenDone() {
const res = await os.apiWithDialog('i/2fa/done', {
token: token.value.toString(),
});
backupCodes.value = res.backupCodes;
page.value++;
confetti({
duration: 1000 * 3,
});
}
function allDone() {
dialog.value.close();
}
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
min-width: 320px;
max-width: calc(100svw - 64px);
box-sizing: border-box;
background: var(--panel);
border-radius: var(--radius);
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_x_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.qr {
width: 20em;
max-width: 100%;
width: 200px;
max-width: 100%;
}
</style>

View File

@@ -8,20 +8,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts['2fa'] }}</template>
<div v-if="$i" class="_gaps_s">
<MkFolder>
<MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'partial'" warn>
{{ i18n.ts._2fa.backupCodeUsedWarning }}
</MkInfo>
<MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'none'" warn>
{{ i18n.ts._2fa.backupCodesExhaustedWarning }}
</MkInfo>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-shield-lock"></i></template>
<template #label>{{ i18n.ts.totp }}</template>
<template #caption>{{ i18n.ts.totpDescription }}</template>
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
<div v-text="i18n.ts._2fa.alreadyRegistered"/>
<template v-if="$i.securityKeysList.length > 0">
<MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
</template>
<MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
<MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
</div>
<MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
<MkButton v-else-if="!$i.twoFactorEnabled" primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
</MkFolder>
<MkFolder>
@@ -85,7 +93,6 @@ withDefaults(defineProps<{
first: false,
});
const twoFactorData = ref<any>(null);
const supportsCredentials = ref(!!navigator.credentials);
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
@@ -102,31 +109,9 @@ async function registerTOTP() {
password: password.result,
});
const qrdialog = await new Promise<boolean>(res => {
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
twoFactorData,
}, {
'ok': () => res(true),
'cancel': () => res(false),
}, 'closed');
});
if (!qrdialog) return;
const token = await os.inputNumber({
title: i18n.ts._2fa.step3Title,
text: i18n.ts._2fa.step3,
autocomplete: 'one-time-code',
});
if (token.canceled) return;
await os.apiWithDialog('i/2fa/done', {
token: token.result.toString(),
});
await os.alert({
type: 'success',
text: i18n.ts._2fa.step4,
});
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
twoFactorData,
}, {}, 'closed');
}
function unregisterTOTP() {

View File

@@ -400,15 +400,6 @@ hr {
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
}
._code {
@extend ._monospace;
background: #2d2d2d;
color: #ccc;
font-size: 14px;
line-height: 1.5;
padding: 5px;
}
.prism-editor__textarea:focus {
outline: none;
}