enhance(frontend/2fa): 二要素認証のバックアップコードを保存するように促す (MisskeyIO#611)

This commit is contained in:
まっちゃとーにゅ 2024-04-14 11:38:00 +09:00 committed by GitHub
parent 7da775df10
commit 1f38f58117
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 100 additions and 33 deletions

View File

@ -2014,9 +2014,12 @@ _2fa:
renewTOTPCancel: "Cancel"
checkBackupCodesBeforeCloseThisWizard: "Before you close this window, please note the following backup codes."
backupCodes: "Backup codes"
backupCodesDescription: "You can use these codes to gain access to your account in case of becoming unable to use your two-factor authentificator app. Each can only be used once. Please keep them in a safe place."
backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentification as soon as possible if you are no longer able to use it."
backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentification app, you will be unable to access this account. Please reconfigure two-factor authentification."
backupCodesDescription: "You can use these codes to gain access to your account in case of becoming unable to use your two-factor authenticator app. Each can only be used once. Please keep them in a safe place."
backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentication as soon as possible if you are no longer able to use it."
backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentication app, you will be unable to access this account. Please reconfigure two-factor authentication."
backupCodesSavedConfirmTitle: "Did you save your backup codes?"
backupCodesSavedConfirmDescription: "If you lose both your two-factor authentication app and backup codes, YOU WILL LOSE ACCESS TO YOUR ACCOUNT.\nKeep them safe and secure, and do not share them with anyone.\n\n$[x2 Two-factor authentication settings CANNOT be changed by anyone other than yourself, $[fg.color=red AND THE ADMINISTRATOR CANNOT DISABLE IT EITHER.]]"
backupCodesSavedConfirmChecked: "I have saved my backup codes"
howto2fa: "If you are having trouble setting up, please refer to {link}."
_permissions:
"read:account": "View your account information"

15
locales/index.d.ts vendored
View File

@ -7856,6 +7856,21 @@ export interface Locale extends ILocale {
* 使
*/
"backupCodesExhaustedWarning": string;
/**
*
*/
"backupCodesSavedConfirmTitle": string;
/**
*
*
*
* $[x2 $[fg.color=red ]]
*/
"backupCodesSavedConfirmDescription": string;
/**
*
*/
"backupCodesSavedConfirmChecked": string;
/**
* {link}
*/

View File

@ -2064,6 +2064,9 @@ _2fa:
backupCodesDescription: "認証アプリが使用できなくなった場合、以下のバックアップコードを使ってアカウントにアクセスできます。これらのコードは必ず安全な場所に保管してください。各コードは一回だけ使用できます。"
backupCodeUsedWarning: "バックアップコードが使用されました。認証アプリが使えなくなっている場合、なるべく早く認証アプリを再設定してください。"
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
backupCodesSavedConfirmTitle: "バックアップコードを保存しましたか?"
backupCodesSavedConfirmDescription: "二要素認証アプリとバックアップコードの両方を紛失した場合、アカウントにアクセスできなくなります。\n誰とも共有せず、適切な方法で保管してください。\n\n$[x2 二要素認証設定は自分以外の誰にも変更できませんので、$[fg.color=red 運営チームも無効化することはできません。]]"
backupCodesSavedConfirmChecked: "バックアップコードを保存しました"
howto2fa: "設定方法でお困りの際は、{link}を参照してください。"
_permissions:

View File

@ -1992,9 +1992,12 @@ _2fa:
renewTOTPCancel: "취소"
checkBackupCodesBeforeCloseThisWizard: "이 위자드를 닫기 전에 아래 백업 코드를 확인하십시오"
backupCodes: "백업 코드"
backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있습니다.이 코드들은 반드시 안전한 장소에 보관하십시오.각 코드는 한 번만 사용할 수 있습니다."
backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요."
backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있습니다. 이 코드들은 반드시 안전한 장소에 보관하십시오. 각 코드는 한 번만 사용할 수 있습니다."
backupCodeUsedWarning: "백업 코드가 사용되었습니다. 인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다. 인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다. 인증 앱을 다시 등록해 주세요."
backupCodesSavedConfirmTitle: "백업 코드를 저장했습니까?"
backupCodesSavedConfirmDescription: "인증 앱과 백업 코드를 모두 분실하면\n계정에 액세스할 수 없게 됩니다.\n자신만이 알 수 있도록 안전한 장소에 보관해 주십시오.\n\n$[x2 2단계 인증 설정은\n본인만이 변경할 수 있으며, $[fg.color=red 운영팀도 해제할 수 없습니다.]]"
backupCodesSavedConfirmChecked: "백업 코드를 저장했습니다"
howto2fa: "설정 방법에 대한 자세한 내용은 {link}를 참조하세요."
_permissions:
"read:account": "계정의 정보를 봅니다"

View File

@ -48,6 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
</template>
</MkSelect>
<MkSwitch v-if="switchLabel" v-model="switchValue" style="display: flex; margin: 1em 0; justify-content: center;">{{ switchLabel }}</MkSwitch>
<details v-if="details" class="_acrylic" style="margin: 1em 0;">
<summary>{{ i18n.ts.details }}</summary>
<div class="_gaps_s" style="text-align: initial;">
@ -58,7 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</details>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okDisabled || okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}<span v-if="okDisabled && okWaitInitiated"> ({{ sec }})</span></MkButton>
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
@ -69,11 +70,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
import { onBeforeUnmount, onMounted, ref, shallowRef, computed, watch } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import { i18n } from '@/i18n.js';
@ -104,6 +106,7 @@ const props = withDefaults(defineProps<{
text?: string | null;
input?: Input;
select?: Select;
switchLabel?: string | null;
details?: Record<string, string>;
actions?: {
text: string;
@ -115,6 +118,8 @@ const props = withDefaults(defineProps<{
showCancelButton?: boolean;
cancelableByBgClick?: boolean;
okText?: string;
okWaitInitiate?: 'dialog' | 'input' | 'switch';
okWaitDuration?: number;
cancelText?: string;
}>(), {
type: 'info',
@ -123,17 +128,20 @@ const props = withDefaults(defineProps<{
text: undefined,
input: undefined,
select: undefined,
switchLabel: undefined,
details: undefined,
actions: undefined,
showOkButton: true,
showCancelButton: false,
cancelableByBgClick: true,
okText: undefined,
okWaitInitiate: undefined,
okWaitDuration: 0,
cancelText: undefined,
});
const emit = defineEmits<{
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result, toggle: boolean }): void;
(ev: 'closed'): void;
}>();
@ -141,6 +149,16 @@ const modal = shallowRef<InstanceType<typeof MkModal>>();
const inputValue = ref<string | number | null>(props.input?.default ?? null);
const selectedValue = ref(props.select?.default ?? null);
const switchValue = ref<boolean>(false);
const sec = ref(props.okWaitDuration);
const okWaitInitiated = computed(() => {
if (props.okWaitInitiate === 'dialog') return true;
if (props.okWaitInitiate === 'input') return inputValue.value !== null;
if (props.okWaitInitiate === 'switch') return switchValue.value;
return false;
});
const okDisabled = computed(() => sec.value > 0);
const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => {
if (props.input) {
@ -161,9 +179,9 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
// overload function 使 lint
function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
function done(canceled: false, result: Result, toggle: boolean): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result, toggle?: boolean ): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result, toggle } as { canceled: true } | { canceled: false, result: Result, toggle: boolean });
modal.value?.close();
}
@ -174,18 +192,13 @@ async function ok() {
props.input ? inputValue.value :
props.select ? selectedValue.value :
true;
done(false, result);
done(false, result, switchValue.value);
}
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
@ -198,8 +211,24 @@ function onInputKeydown(evt: KeyboardEvent) {
}
}
watch(okWaitInitiated, () => {
sec.value = props.okWaitDuration;
});
onMounted(() => {
document.addEventListener('keydown', onKeydown);
sec.value = props.okWaitDuration;
if (sec.value > 0) {
const waitTimer = setInterval(() => {
if (!okWaitInitiated.value) return;
if (sec.value < 0) {
clearInterval(waitTimer);
}
sec.value = sec.value - 1;
}, 1000);
}
});
onBeforeUnmount(() => {

View File

@ -221,7 +221,10 @@ export function alert(props: {
type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null;
text?: string | null;
switchLabel?: string | null;
details?: Record<string, string>;
okWaitInitiate?: 'dialog' | 'input' | 'switch';
okWaitDuration?: number;
}): Promise<void> {
return new Promise(resolve => {
popup(MkDialog, props, {
@ -236,8 +239,11 @@ export function confirm(props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null;
text?: string | null;
switchLabel?: string | null;
details?: Record<string, string>;
okText?: string;
okWaitInitiate?: 'dialog' | 'input' | 'switch';
okWaitDuration?: number;
cancelText?: string;
}): Promise<{ canceled: boolean }> {
return new Promise(resolve => {

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow
ref="dialog"
:width="500"
:height="550"
:height="600"
@close="cancel"
@closed="emit('closed')"
>
@ -76,15 +76,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<MkInfo warn>{{ i18n.ts._2fa.backupCodesDescription }}</MkInfo>
<MkButton primary rounded gradate full @click="downloadBackupCodes"><i class="ti ti-download"></i> {{ i18n.ts.download }}</MkButton>
<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>
<span style="text-align: center;">#{{ i + 1 }}. <code class="_monospace">{{ code }}</code></span>
</div>
<MkButton primary rounded gradate @click="downloadBackupCodes"><i class="ti ti-download"></i> {{ i18n.ts.download }}</MkButton>
</div>
</MkFolder>
</div>
@ -107,6 +103,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
import MkInput from '@/components/MkInput.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import * as config from '@/config.js';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import { confetti } from '@/scripts/confetti.js';
@ -131,7 +128,8 @@ const token = ref<string | number | null>(null);
const backupCodes = ref<string[]>();
function cancel() {
dialog.value.close();
if (page.value !== 2) dialog.value?.close();
else allDone();
}
async function tokenDone() {
@ -150,15 +148,27 @@ async function tokenDone() {
function downloadBackupCodes() {
if (backupCodes.value !== undefined) {
const txtBlob = new Blob([backupCodes.value.join('\n')], { type: 'text/plain' });
const txtBlob = new Blob([backupCodes.value.reduce((acc, code, i) => `${acc}#${i + 1}. ${code}\r\n`, `${config.hostname} 2FA Backup Codes\r\n\r\n`)], { type: 'text/plain' });
const dummya = document.createElement('a');
dummya.href = URL.createObjectURL(txtBlob);
dummya.download = `${$i.username}-2fa-backup-codes.txt`;
dummya.download = `${config.hostname}-${$i.username}-2fa-backup-codes.txt`;
dummya.click();
}
}
function allDone() {
async function allDone() {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.ts._2fa.backupCodesSavedConfirmTitle,
text: i18n.ts._2fa.backupCodesSavedConfirmDescription,
switchLabel: i18n.ts._2fa.backupCodesSavedConfirmChecked,
okText: i18n.ts.gotIt,
okWaitInitiate: 'switch',
okWaitDuration: 5,
});
if (canceled) return;
dialog.value.close();
}
</script>

View File

@ -25,13 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo>
<Mfm :text="i18n.tsx._2fa.howto2fa({ link: `[${i18n.ts.here}](https://go.misskey.io/howto-2fa)`})"/>
</MkInfo>
<MkInfo v-if="$i.securityKeysList.length > 0">{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
<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-if="$i.securityKeysList.length > 0" @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
<MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
</div>