feat: Refine 2fa (#11766)
* wip
* Update 2fa.qrdialog.vue
* Update 2fa.vue
* Update CHANGELOG.md
* tweak
* ✌️
			
			
This commit is contained in:
		@@ -0,0 +1,11 @@
 | 
			
		||||
export class User2faBackupCodes1690569881926 {
 | 
			
		||||
	name = 'User2faBackupCodes1690569881926'
 | 
			
		||||
 | 
			
		||||
	async up(queryRunner) {
 | 
			
		||||
		await queryRunner.query(`ALTER TABLE "user_profile" ADD "twoFactorBackupSecret" character varying array`);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async down(queryRunner) {
 | 
			
		||||
		await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "twoFactorBackupSecret"`);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -434,6 +434,7 @@ export class UserEntityService implements OnModuleInit {
 | 
			
		||||
				preventAiLearning: profile!.preventAiLearning,
 | 
			
		||||
				isExplorable: user.isExplorable,
 | 
			
		||||
				isDeleted: user.isDeleted,
 | 
			
		||||
				twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
 | 
			
		||||
				hideOnlineStatus: user.hideOnlineStatus,
 | 
			
		||||
				hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
 | 
			
		||||
					where: { userId: user.id, isSpecified: true },
 | 
			
		||||
 
 | 
			
		||||
@@ -101,6 +101,11 @@ export class MiUserProfile {
 | 
			
		||||
	})
 | 
			
		||||
	public twoFactorSecret: string | null;
 | 
			
		||||
 | 
			
		||||
	@Column('varchar', {
 | 
			
		||||
		nullable: true, array: true,
 | 
			
		||||
	})
 | 
			
		||||
	public twoFactorBackupSecret: string[] | null;
 | 
			
		||||
 | 
			
		||||
	@Column('boolean', {
 | 
			
		||||
		default: false,
 | 
			
		||||
	})
 | 
			
		||||
 
 | 
			
		||||
@@ -321,6 +321,11 @@ export const packedMeDetailedOnlySchema = {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			nullable: false, optional: false,
 | 
			
		||||
		},
 | 
			
		||||
		twoFactorBackupCodesStock: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			enum: ['full', 'partial', 'none'],
 | 
			
		||||
			nullable: false, optional: false,
 | 
			
		||||
		},
 | 
			
		||||
		hideOnlineStatus: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			nullable: false, optional: false,
 | 
			
		||||
 
 | 
			
		||||
@@ -160,6 +160,13 @@ export class SigninApiService {
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (profile.twoFactorBackupSecret?.includes(token)) {
 | 
			
		||||
				await this.userProfilesRepository.update({ userId: profile.userId }, {
 | 
			
		||||
					twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
 | 
			
		||||
				});
 | 
			
		||||
				return this.signinService.signin(request, reply, user);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const delta = OTPAuth.TOTP.validate({
 | 
			
		||||
				secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
 | 
			
		||||
				digits: 6,
 | 
			
		||||
 
 | 
			
		||||
@@ -54,8 +54,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		||||
				throw new Error('not verified');
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const backupCodes = Array.from({ length: 5 }, () => new OTPAuth.Secret().base32);
 | 
			
		||||
 | 
			
		||||
			await this.userProfilesRepository.update(me.id, {
 | 
			
		||||
				twoFactorSecret: profile.twoFactorTempSecret,
 | 
			
		||||
				twoFactorBackupSecret: backupCodes,
 | 
			
		||||
				twoFactorEnabled: true,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
@@ -64,6 +67,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		||||
				detail: true,
 | 
			
		||||
				includeSecrets: true,
 | 
			
		||||
			}));
 | 
			
		||||
 | 
			
		||||
			return {
 | 
			
		||||
				backupCodes: backupCodes,
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -46,6 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		||||
 | 
			
		||||
			await this.userProfilesRepository.update(me.id, {
 | 
			
		||||
				twoFactorSecret: null,
 | 
			
		||||
				twoFactorBackupSecret: null,
 | 
			
		||||
				twoFactorEnabled: false,
 | 
			
		||||
				usePasswordLessLogin: false,
 | 
			
		||||
			});
 | 
			
		||||
 
 | 
			
		||||
@@ -191,7 +191,7 @@ describe('2要素認証', () => {
 | 
			
		||||
		const doneResponse = await api('/i/2fa/done', {
 | 
			
		||||
			token: otpToken(registerResponse.body.secret),
 | 
			
		||||
		}, alice);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 204);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 200);
 | 
			
		||||
 | 
			
		||||
		const usersShowResponse = await api('/users/show', {
 | 
			
		||||
			username,
 | 
			
		||||
@@ -216,7 +216,7 @@ describe('2要素認証', () => {
 | 
			
		||||
		const doneResponse = await api('/i/2fa/done', {
 | 
			
		||||
			token: otpToken(registerResponse.body.secret),
 | 
			
		||||
		}, alice);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 204);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 200);
 | 
			
		||||
 | 
			
		||||
		const registerKeyResponse = await api('/i/2fa/register-key', {
 | 
			
		||||
			password,
 | 
			
		||||
@@ -272,7 +272,7 @@ describe('2要素認証', () => {
 | 
			
		||||
		const doneResponse = await api('/i/2fa/done', {
 | 
			
		||||
			token: otpToken(registerResponse.body.secret),
 | 
			
		||||
		}, alice);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 204);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 200);
 | 
			
		||||
 | 
			
		||||
		const registerKeyResponse = await api('/i/2fa/register-key', {
 | 
			
		||||
			password,
 | 
			
		||||
@@ -329,7 +329,7 @@ describe('2要素認証', () => {
 | 
			
		||||
		const doneResponse = await api('/i/2fa/done', {
 | 
			
		||||
			token: otpToken(registerResponse.body.secret),
 | 
			
		||||
		}, alice);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 204);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 200);
 | 
			
		||||
 | 
			
		||||
		const registerKeyResponse = await api('/i/2fa/register-key', {
 | 
			
		||||
			password,
 | 
			
		||||
@@ -371,7 +371,7 @@ describe('2要素認証', () => {
 | 
			
		||||
		const doneResponse = await api('/i/2fa/done', {
 | 
			
		||||
			token: otpToken(registerResponse.body.secret),
 | 
			
		||||
		}, alice);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 204);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 200);
 | 
			
		||||
 | 
			
		||||
		const registerKeyResponse = await api('/i/2fa/register-key', {
 | 
			
		||||
			password,
 | 
			
		||||
@@ -423,7 +423,7 @@ describe('2要素認証', () => {
 | 
			
		||||
		const doneResponse = await api('/i/2fa/done', {
 | 
			
		||||
			token: otpToken(registerResponse.body.secret),
 | 
			
		||||
		}, alice);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 204);
 | 
			
		||||
		assert.strictEqual(doneResponse.status, 200);
 | 
			
		||||
 | 
			
		||||
		const usersShowResponse = await api('/users/show', {
 | 
			
		||||
			username,
 | 
			
		||||
 
 | 
			
		||||
@@ -152,6 +152,7 @@ describe('ユーザー', () => {
 | 
			
		||||
			preventAiLearning: user.preventAiLearning,
 | 
			
		||||
			isExplorable: user.isExplorable,
 | 
			
		||||
			isDeleted: user.isDeleted,
 | 
			
		||||
			twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
 | 
			
		||||
			hideOnlineStatus: user.hideOnlineStatus,
 | 
			
		||||
			hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
 | 
			
		||||
			hasUnreadMentions: user.hasUnreadMentions,
 | 
			
		||||
@@ -398,6 +399,7 @@ describe('ユーザー', () => {
 | 
			
		||||
		assert.strictEqual(response.preventAiLearning, true);
 | 
			
		||||
		assert.strictEqual(response.isExplorable, true);
 | 
			
		||||
		assert.strictEqual(response.isDeleted, false);
 | 
			
		||||
		assert.strictEqual(response.twoFactorBackupCodesStock, 'none');
 | 
			
		||||
		assert.strictEqual(response.hideOnlineStatus, false);
 | 
			
		||||
		assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
 | 
			
		||||
		assert.strictEqual(response.hasUnreadMentions, false);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import fs from 'node:fs/promises';
 | 
			
		||||
import { fileURLToPath } from 'node:url';
 | 
			
		||||
import path from 'node:path';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import type { entities } from 'misskey-js'
 | 
			
		||||
 | 
			
		||||
export function abuseUserReport() {
 | 
			
		||||
@@ -110,6 +115,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
 | 
			
		||||
		publicReactions: false,
 | 
			
		||||
		securityKeys: false,
 | 
			
		||||
		twoFactorEnabled: false,
 | 
			
		||||
		twoFactorBackupCodesStock: 'none',
 | 
			
		||||
		updatedAt: null,
 | 
			
		||||
		uri: null,
 | 
			
		||||
		url: null,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { existsSync, readFileSync } from 'node:fs';
 | 
			
		||||
import { writeFile } from 'node:fs/promises';
 | 
			
		||||
import { basename, dirname } from 'node:path/posix';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { resolve } from 'node:path';
 | 
			
		||||
import { fileURLToPath } from 'node:url';
 | 
			
		||||
import type { StorybookConfig } from '@storybook/vue3-vite';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { addons } from '@storybook/manager-api';
 | 
			
		||||
import { create } from '@storybook/theming/create';
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { type SharedOptions, rest } from 'msw';
 | 
			
		||||
 | 
			
		||||
export const onUnhandledRequest = ((req, print) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { writeFile } from 'node:fs/promises';
 | 
			
		||||
import locales from '../../../locales/index.js';
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { readFile, writeFile } from 'node:fs/promises';
 | 
			
		||||
import JSON5 from 'json5';
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { addons } from '@storybook/addons';
 | 
			
		||||
import { FORCE_REMOUNT } from '@storybook/core-events';
 | 
			
		||||
import { type Preview, setup } from '@storybook/vue3';
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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() {
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2462,6 +2462,7 @@ type MeDetailed = UserDetailed & {
 | 
			
		||||
    receiveAnnouncementEmail: boolean;
 | 
			
		||||
    usePasswordLessLogin: boolean;
 | 
			
		||||
    unreadAnnouncements: Announcement[];
 | 
			
		||||
    twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
 | 
			
		||||
    [other: string]: any;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -105,6 +105,7 @@ export type MeDetailed = UserDetailed & {
 | 
			
		||||
	receiveAnnouncementEmail: boolean;
 | 
			
		||||
	usePasswordLessLogin: boolean;
 | 
			
		||||
	unreadAnnouncements: Announcement[];
 | 
			
		||||
	twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
 | 
			
		||||
	[other: string]: any;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user