🎨 2FA設定のデザイン向上 / セキュリティキーの名前を変更できるように (#9985)
* wip * fix * wip * wip * ✌️ * rename key * 🎨 * update CHANGELOG.md * パスワードレスログインの判断はサーバーで * 日本語 * 日本語 * 日本語 * 日本語 * ✌️ * fix * refactor * トークン→確認コード * fix password-less / qr click * use otpauth * 日本語 * autocomplete * パスワードレス設定は外に出す * 🎨 * 🎨 --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
		| @@ -12,6 +12,8 @@ You should also include the user name that made the change. | ||||
|  | ||||
| ### Improvements | ||||
| - Server: URLプレビュー(summaly)はプロキシを通すように | ||||
| - Client: 2FA設定のUIをまともにした | ||||
| - セキュリティキーの名前を変更できるように | ||||
|  | ||||
| ### Bugfixes | ||||
| - | ||||
|   | ||||
| @@ -392,17 +392,20 @@ userList: "リスト" | ||||
| about: "情報" | ||||
| aboutMisskey: "Misskeyについて" | ||||
| administrator: "管理者" | ||||
| token: "トークン" | ||||
| twoStepAuthentication: "二段階認証" | ||||
| token: "確認コード" | ||||
| 2fa: "二要素認証" | ||||
| totp: "認証アプリ" | ||||
| totpDescription: "認証アプリを使ってワンタイムパスワードを入力" | ||||
| moderator: "モデレーター" | ||||
| moderation: "モデレーション" | ||||
| nUsersMentioned: "{n}人が投稿" | ||||
| securityKeyAndPasskey: "セキュリティキー・パスキー" | ||||
| securityKey: "セキュリティキー" | ||||
| securityKeyName: "キーの名前" | ||||
| registerSecurityKey: "セキュリティキーを登録する" | ||||
| lastUsed: "最後の使用" | ||||
| lastUsedAt: "最後の使用: {t}" | ||||
| unregister: "登録を解除" | ||||
| passwordLessLogin: "パスワード無しログイン" | ||||
| passwordLessLogin: "パスワードレスログイン" | ||||
| passwordLessLoginDescription: "パスワードを使用せず、セキュリティキーやパスキーなどのみでログインします" | ||||
| resetPassword: "パスワードをリセット" | ||||
| newPasswordIs: "新しいパスワードは「{password}」です" | ||||
| reduceUiAnimation: "UIのアニメーションを減らす" | ||||
| @@ -447,7 +450,6 @@ passwordMatched: "一致しました" | ||||
| passwordNotMatched: "一致していません" | ||||
| signinWith: "{x}でログイン" | ||||
| signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" | ||||
| tapSecurityKey: "セキュリティキーにタッチ" | ||||
| or: "もしくは" | ||||
| language: "言語" | ||||
| uiLanguage: "UIの表示言語" | ||||
| @@ -1519,14 +1521,29 @@ _tutorial: | ||||
|  | ||||
| _2fa: | ||||
|   alreadyRegistered: "既に設定は完了しています。" | ||||
|   registerDevice: "デバイスを登録" | ||||
|   registerKey: "キーを登録" | ||||
|   registerTOTP: "認証アプリの設定を開始" | ||||
|   passwordToTOTP: "パスワードを入力してください" | ||||
|   step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" | ||||
|   step2: "次に、表示されているQRコードをアプリでスキャンします。" | ||||
|   step2Url: "デスクトップアプリでは次のURLを入力します:" | ||||
|   step3: "アプリに表示されているトークンを入力して完了です。" | ||||
|   step4: "これからログインするときも、同じようにトークンを入力します。" | ||||
|   securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" | ||||
|   step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。" | ||||
|   step2Url: "デスクトップアプリでは次のURIを入力します:" | ||||
|   step3Title: "確認コードを入力" | ||||
|   step3: "アプリに表示されている確認コード(トークン)を入力して完了です。" | ||||
|   step4: "これからログインするときも、同じように確認コードを入力します。" | ||||
|   securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" | ||||
|   registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" | ||||
|   securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" | ||||
|   chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。" | ||||
|   registerSecurityKey: "セキュリティキー・パスキーを登録する" | ||||
|   securityKeyName: "キーの名前を入力" | ||||
|   tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください" | ||||
|   removeKey: "セキュリティキーを削除" | ||||
|   removeKeyConfirm: "{name}を削除しますか?" | ||||
|   whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。" | ||||
|   renewTOTP: "認証アプリを再設定" | ||||
|   renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります" | ||||
|   renewTOTPOk: "再設定する" | ||||
|   renewTOTPCancel: "やめておく" | ||||
|  | ||||
| _permissions: | ||||
|   "read:account": "アカウントの情報を見る" | ||||
| @@ -1861,3 +1878,7 @@ _deck: | ||||
|     channel: "チャンネル" | ||||
|     mentions: "あなた宛て" | ||||
|     direct: "ダイレクト" | ||||
|  | ||||
| _dialog: | ||||
|   charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" | ||||
|   charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" | ||||
|   | ||||
| @@ -85,6 +85,7 @@ | ||||
| 		"nsfwjs": "2.4.2", | ||||
| 		"oauth": "0.10.0", | ||||
| 		"os-utils": "0.0.14", | ||||
| 		"otpauth": "^9.0.2", | ||||
| 		"parse5": "7.1.2", | ||||
| 		"pg": "8.9.0", | ||||
| 		"private-ip": "3.0.0", | ||||
| @@ -108,7 +109,6 @@ | ||||
| 		"seedrandom": "3.0.5", | ||||
| 		"semver": "7.3.8", | ||||
| 		"sharp": "0.31.3", | ||||
| 		"speakeasy": "2.0.0", | ||||
| 		"strict-event-emitter-types": "2.0.0", | ||||
| 		"stringz": "2.1.0", | ||||
| 		"summaly": "github:misskey-dev/summaly", | ||||
| @@ -167,7 +167,6 @@ | ||||
| 		"@types/semver": "7.3.13", | ||||
| 		"@types/sharp": "0.31.1", | ||||
| 		"@types/sinonjs__fake-timers": "8.1.2", | ||||
| 		"@types/speakeasy": "2.0.7", | ||||
| 		"@types/tinycolor2": "1.4.3", | ||||
| 		"@types/tmp": "0.2.3", | ||||
| 		"@types/unzipper": "0.10.5", | ||||
|   | ||||
| @@ -170,6 +170,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; | ||||
| import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; | ||||
| import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; | ||||
| import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; | ||||
| import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; | ||||
| import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; | ||||
| import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; | ||||
| import * as ep___i_apps from './endpoints/i/apps.js'; | ||||
| @@ -486,6 +487,7 @@ const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___ | ||||
| const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default }; | ||||
| const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default }; | ||||
| const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default }; | ||||
| const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default }; | ||||
| const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default }; | ||||
| const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; | ||||
| const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; | ||||
| @@ -806,6 +808,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | ||||
| 		$i_2fa_passwordLess, | ||||
| 		$i_2fa_registerKey, | ||||
| 		$i_2fa_register, | ||||
| 		$i_2fa_updateKey, | ||||
| 		$i_2fa_removeKey, | ||||
| 		$i_2fa_unregister, | ||||
| 		$i_apps, | ||||
| @@ -1120,6 +1123,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | ||||
| 		$i_2fa_passwordLess, | ||||
| 		$i_2fa_registerKey, | ||||
| 		$i_2fa_register, | ||||
| 		$i_2fa_updateKey, | ||||
| 		$i_2fa_removeKey, | ||||
| 		$i_2fa_unregister, | ||||
| 		$i_apps, | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { randomBytes } from 'node:crypto'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import * as speakeasy from 'speakeasy'; | ||||
| import * as OTPAuth from "otpauth"; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; | ||||
| @@ -155,19 +155,19 @@ export class SigninApiService { | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			const verified = (speakeasy as any).totp.verify({ | ||||
| 				secret: profile.twoFactorSecret, | ||||
| 				encoding: 'base32', | ||||
| 				token: token, | ||||
| 				window: 2, | ||||
| 			const delta = OTPAuth.TOTP.validate({ | ||||
| 				secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret), | ||||
| 				digits: 6, | ||||
| 				token, | ||||
| 				window: 1, | ||||
| 			}); | ||||
|  | ||||
| 			if (verified) { | ||||
| 				return this.signinService.signin(request, reply, user); | ||||
| 			} else { | ||||
| 			if (delta === null) { | ||||
| 				return await fail(403, { | ||||
| 					id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', | ||||
| 				}); | ||||
| 			} else { | ||||
| 				return this.signinService.signin(request, reply, user); | ||||
| 			} | ||||
| 		} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) { | ||||
| 			if (!same && !profile.usePasswordLessLogin) { | ||||
|   | ||||
| @@ -170,6 +170,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; | ||||
| import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; | ||||
| import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; | ||||
| import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; | ||||
| import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; | ||||
| import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; | ||||
| import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; | ||||
| import * as ep___i_apps from './endpoints/i/apps.js'; | ||||
| @@ -484,6 +485,7 @@ const eps = [ | ||||
| 	['i/2fa/password-less', ep___i_2fa_passwordLess], | ||||
| 	['i/2fa/register-key', ep___i_2fa_registerKey], | ||||
| 	['i/2fa/register', ep___i_2fa_register], | ||||
| 	['i/2fa/update-key', ep___i_2fa_updateKey], | ||||
| 	['i/2fa/remove-key', ep___i_2fa_removeKey], | ||||
| 	['i/2fa/unregister', ep___i_2fa_unregister], | ||||
| 	['i/apps', ep___i_apps], | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| import * as speakeasy from 'speakeasy'; | ||||
| import * as OTPAuth from 'otpauth'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import type { UserProfilesRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -22,8 +25,14 @@ export const paramDef = { | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const token = ps.token.replace(/\s/g, ''); | ||||
| @@ -34,13 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				throw new Error('二段階認証の設定が開始されていません'); | ||||
| 			} | ||||
|  | ||||
| 			const verified = (speakeasy as any).totp.verify({ | ||||
| 				secret: profile.twoFactorTempSecret, | ||||
| 				encoding: 'base32', | ||||
| 				token: token, | ||||
| 			const delta = OTPAuth.TOTP.validate({ | ||||
| 				secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret), | ||||
| 				digits: 6, | ||||
| 				token, | ||||
| 				window: 1, | ||||
| 			}); | ||||
|  | ||||
| 			if (!verified) { | ||||
| 			if (delta === null) { | ||||
| 				throw new Error('not verified'); | ||||
| 			} | ||||
|  | ||||
| @@ -48,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				twoFactorSecret: profile.twoFactorTempSecret, | ||||
| 				twoFactorEnabled: true, | ||||
| 			}); | ||||
|  | ||||
| 			// Publish meUpdated event | ||||
| 			this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { | ||||
| 				detail: true, | ||||
| 				includeSecrets: true, | ||||
| 			})); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -25,7 +25,7 @@ export const paramDef = { | ||||
| 		attestationObject: { type: 'string' }, | ||||
| 		password: { type: 'string' }, | ||||
| 		challengeId: { type: 'string' }, | ||||
| 		name: { type: 'string' }, | ||||
| 		name: { type: 'string', minLength: 1, maxLength: 30 }, | ||||
| 	}, | ||||
| 	required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], | ||||
| } as const; | ||||
|   | ||||
| @@ -1,12 +1,23 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { UserProfilesRepository } from '@/models/index.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		noKey: { | ||||
| 			message: 'No security key.', | ||||
| 			code: 'NO_SECURITY_KEY', | ||||
| 			id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| @@ -23,11 +34,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.userSecurityKeysRepository) | ||||
| 		private userSecurityKeysRepository: UserSecurityKeysRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			if (ps.value === true) { | ||||
| 				// セキュリティキーがなければパスワードレスを有効にはできない | ||||
| 				const keyCount = await this.userSecurityKeysRepository.count({ | ||||
| 					where: { | ||||
| 						userId: me.id, | ||||
| 					}, | ||||
| 					select: { | ||||
| 						id: true, | ||||
| 						name: true, | ||||
| 						lastUsed: true, | ||||
| 					}, | ||||
| 				}); | ||||
|  | ||||
| 				if (keyCount === 0) { | ||||
| 					await this.userProfilesRepository.update(me.id, { | ||||
| 						usePasswordLessLogin: false, | ||||
| 					}); | ||||
|  | ||||
| 					throw new ApiError(meta.errors.noKey); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			await this.userProfilesRepository.update(me.id, { | ||||
| 				usePasswordLessLogin: ps.value, | ||||
| 			}); | ||||
|  | ||||
| 			// Publish meUpdated event | ||||
| 			this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { | ||||
| 				detail: true, | ||||
| 				includeSecrets: true, | ||||
| 			})); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import * as speakeasy from 'speakeasy'; | ||||
| import * as OTPAuth from 'otpauth'; | ||||
| import * as QRCode from 'qrcode'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import type { UserProfilesRepository } from '@/models/index.js'; | ||||
| @@ -42,25 +42,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 			} | ||||
|  | ||||
| 			// Generate user's secret key | ||||
| 			const secret = speakeasy.generateSecret({ | ||||
| 				length: 32, | ||||
| 			}); | ||||
| 			const secret = new OTPAuth.Secret(); | ||||
|  | ||||
| 			await this.userProfilesRepository.update(me.id, { | ||||
| 				twoFactorTempSecret: secret.base32, | ||||
| 			}); | ||||
|  | ||||
| 			// Get the data URL of the authenticator URL | ||||
| 			const url = speakeasy.otpauthURL({ | ||||
| 				secret: secret.base32, | ||||
| 				encoding: 'base32', | ||||
| 			const totp = new OTPAuth.TOTP({ | ||||
| 				secret, | ||||
| 				digits: 6, | ||||
| 				label: me.username, | ||||
| 				issuer: this.config.host, | ||||
| 			}); | ||||
| 			const dataUrl = await QRCode.toDataURL(url); | ||||
| 			const url = totp.toString(); | ||||
| 			const qr = await QRCode.toDataURL(url); | ||||
|  | ||||
| 			return { | ||||
| 				qr: dataUrl, | ||||
| 				qr, | ||||
| 				url, | ||||
| 				secret: secret.base32, | ||||
| 				label: me.username, | ||||
|   | ||||
| @@ -50,6 +50,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				id: ps.credentialId, | ||||
| 			}); | ||||
|  | ||||
| 			// 使われているキーがなくなったらパスワードレスログインをやめる | ||||
| 			const keyCount = await this.userSecurityKeysRepository.count({ | ||||
| 				where: { | ||||
| 					userId: me.id, | ||||
| 				}, | ||||
| 				select: { | ||||
| 					id: true, | ||||
| 					name: true, | ||||
| 					lastUsed: true, | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			if (keyCount === 0) { | ||||
| 				await this.userProfilesRepository.update(me.id, { | ||||
| 					usePasswordLessLogin: false, | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			// Publish meUpdated event | ||||
| 			this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { | ||||
| 				detail: true, | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import type { UserProfilesRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -24,6 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); | ||||
| @@ -38,7 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 			await this.userProfilesRepository.update(me.id, { | ||||
| 				twoFactorSecret: null, | ||||
| 				twoFactorEnabled: false, | ||||
| 				usePasswordLessLogin: false, | ||||
| 			}); | ||||
|  | ||||
| 			// Publish meUpdated event | ||||
| 			this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { | ||||
| 				detail: true, | ||||
| 				includeSecrets: true, | ||||
| 			})); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,78 @@ | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
|  | ||||
| 	secure: true, | ||||
|  | ||||
| 	errors: { | ||||
| 		noSuchKey: { | ||||
| 			message: 'No such key.', | ||||
| 			code: 'NO_SUCH_KEY', | ||||
| 			id: 'f9c5467f-d492-4d3c-9a8g-a70dacc86512', | ||||
| 		}, | ||||
|  | ||||
| 		accessDenied: { | ||||
| 			message: 'You do not have edit privilege of the channel.', | ||||
| 			code: 'ACCESS_DENIED', | ||||
| 			id: '1fb7cb09-d46a-4fff-b8df-057708cce513', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		name: { type: 'string', minLength: 1, maxLength: 30 }, | ||||
| 		credentialId: { type: 'string' }, | ||||
| 	}, | ||||
| 	required: ['name', 'credentialId'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.userSecurityKeysRepository) | ||||
| 		private userSecurityKeysRepository: UserSecurityKeysRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const key = await this.userSecurityKeysRepository.findOneBy({ | ||||
| 				id: ps.credentialId, | ||||
| 			}); | ||||
|  | ||||
| 			if (key == null) { | ||||
| 				throw new ApiError(meta.errors.noSuchKey); | ||||
| 			} | ||||
|  | ||||
| 			if (key.userId !== me.id) { | ||||
| 				throw new ApiError(meta.errors.accessDenied); | ||||
| 			} | ||||
| 	 | ||||
| 			await this.userSecurityKeysRepository.update(key.id, { | ||||
| 				name: ps.name, | ||||
| 			}); | ||||
|  | ||||
| 			// Publish meUpdated event | ||||
| 			this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { | ||||
| 				detail: true, | ||||
| 				includeSecrets: true, | ||||
| 			})); | ||||
|  | ||||
| 			return {}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -14,8 +14,12 @@ | ||||
| 		</div> | ||||
| 		<header v-if="title" :class="$style.title"><Mfm :text="title"/></header> | ||||
| 		<div v-if="text" :class="$style.text"><Mfm :text="text"/></div> | ||||
| 		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown"> | ||||
| 		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> | ||||
| 			<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> | ||||
| 			<template #caption> | ||||
| 				<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" /> | ||||
| 				<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" /> | ||||
| 			</template> | ||||
| 		</MkInput> | ||||
| 		<MkSelect v-if="select" v-model="selectedValue" autofocus> | ||||
| 			<template v-if="select.items"> | ||||
| @@ -28,7 +32,7 @@ | ||||
| 			</template> | ||||
| 		</MkSelect> | ||||
| 		<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> | ||||
| 			<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> | ||||
| 			<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> | ||||
| 			<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> | ||||
| 		</div> | ||||
| 		<div v-if="actions" :class="$style.buttons"> | ||||
| @@ -47,9 +51,12 @@ import MkSelect from '@/components/MkSelect.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | ||||
| type Input = { | ||||
| 	type: HTMLInputElement['type']; | ||||
| 	type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; | ||||
| 	placeholder?: string | null; | ||||
| 	default: any | null; | ||||
| 	autocomplete?: string; | ||||
| 	default: string | number | null; | ||||
| 	minLength?: number; | ||||
| 	maxLength?: number; | ||||
| }; | ||||
|  | ||||
| type Select = { | ||||
| @@ -98,8 +105,28 @@ const emit = defineEmits<{ | ||||
|  | ||||
| const modal = shallowRef<InstanceType<typeof MkModal>>(); | ||||
|  | ||||
| const inputValue = ref(props.input?.default || null); | ||||
| const selectedValue = ref(props.select?.default || null); | ||||
| const inputValue = ref<string | number | null>(props.input?.default ?? null); | ||||
| const selectedValue = ref(props.select?.default ?? null); | ||||
|  | ||||
| let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null); | ||||
| const okButtonDisabled = $computed<boolean>(() => { | ||||
| 	if (props.input) { | ||||
| 		if (props.input.minLength) { | ||||
| 			if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) { | ||||
| 				disabledReason = 'charactersBelow'; | ||||
| 				return true; | ||||
| 			} | ||||
| 		} | ||||
| 		if (props.input.maxLength) { | ||||
| 			if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) { | ||||
| 				disabledReason = 'charactersExceeded'; | ||||
| 				return true; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return false; | ||||
| }); | ||||
|  | ||||
| function done(canceled: boolean, result?) { | ||||
| 	emit('done', { canceled, result }); | ||||
|   | ||||
| @@ -1,13 +1,20 @@ | ||||
| <template> | ||||
| <div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]"> | ||||
| 	<div :class="$style.header" class="_button" @click="toggle"> | ||||
| 		<span :class="$style.headerIcon"><slot name="icon"></slot></span> | ||||
| 		<span :class="$style.headerText"><slot name="label"></slot></span> | ||||
| 		<span :class="$style.headerRight"> | ||||
| 		<div :class="$style.headerIcon"><slot name="icon"></slot></div> | ||||
| 		<div :class="$style.headerText"> | ||||
| 			<div :class="$style.headerTextMain"> | ||||
| 				<slot name="label"></slot> | ||||
| 			</div> | ||||
| 			<div :class="$style.headerTextSub"> | ||||
| 				<slot name="caption"></slot> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div :class="$style.headerRight"> | ||||
| 			<span :class="$style.headerRightText"><slot name="suffix"></slot></span> | ||||
| 			<i v-if="opened" class="ti ti-chevron-up icon"></i> | ||||
| 			<i v-else class="ti ti-chevron-down icon"></i> | ||||
| 		</span> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }"> | ||||
| 		<Transition | ||||
| @@ -139,6 +146,17 @@ onMounted(() => { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .headerUpper { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| } | ||||
|  | ||||
| .headerLower { | ||||
| 	color: var(--fgTransparentWeak); | ||||
|     font-size: .85em; | ||||
| 	padding-left: 4px; | ||||
| } | ||||
|  | ||||
| .headerIcon { | ||||
| 	margin-right: 0.75em; | ||||
| 	flex-shrink: 0; | ||||
| @@ -161,6 +179,15 @@ onMounted(() => { | ||||
| 	padding-right: 12px; | ||||
| } | ||||
|  | ||||
| .headerTextMain { | ||||
|  | ||||
| } | ||||
|  | ||||
| .headerTextSub { | ||||
| 	color: var(--fgTransparentWeak); | ||||
| 	font-size: .85em; | ||||
| } | ||||
|  | ||||
| .headerRight { | ||||
| 	margin-left: auto; | ||||
| 	opacity: 0.7; | ||||
|   | ||||
| @@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	modelValue: string | number; | ||||
| 	modelValue: string | number | null; | ||||
| 	type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; | ||||
| 	required?: boolean; | ||||
| 	readonly?: boolean; | ||||
| @@ -49,7 +49,7 @@ const props = defineProps<{ | ||||
| 	pattern?: string; | ||||
| 	placeholder?: string; | ||||
| 	autofocus?: boolean; | ||||
| 	autocomplete?: boolean; | ||||
| 	autocomplete?: string; | ||||
| 	spellcheck?: boolean; | ||||
| 	step?: any; | ||||
| 	datalist?: string[]; | ||||
|   | ||||
| @@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	modelValue: string; | ||||
| 	modelValue: string | null; | ||||
| 	required?: boolean; | ||||
| 	readonly?: boolean; | ||||
| 	disabled?: boolean; | ||||
| @@ -48,7 +48,7 @@ const props = defineProps<{ | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'change', _ev: KeyboardEvent): void; | ||||
| 	(ev: 'update:modelValue', value: string): void; | ||||
| 	(ev: 'update:modelValue', value: string | null): void; | ||||
| }>(); | ||||
|  | ||||
| const slots = useSlots(); | ||||
|   | ||||
| @@ -10,7 +10,7 @@ | ||||
| 				<template #prefix>@</template> | ||||
| 				<template #suffix>@{{ host }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password> | ||||
| 			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :with-password-toggle="true" required data-cy-signin-password> | ||||
| 				<template #prefix><i class="ti ti-lock"></i></template> | ||||
| 				<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> | ||||
| 			</MkInput> | ||||
| @@ -28,11 +28,11 @@ | ||||
| 			</div> | ||||
| 			<div class="twofa-group totp-group"> | ||||
| 				<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p> | ||||
| 				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required> | ||||
| 				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :with-password-toggle="true" required> | ||||
| 					<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="off" :spellcheck="false" required> | ||||
| 				<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required> | ||||
| 					<template #label>{{ i18n.ts.token }}</template> | ||||
| 					<template #prefix><i class="ti ti-123"></i></template> | ||||
| 				</MkInput> | ||||
|   | ||||
| @@ -246,7 +246,10 @@ export function inputText(props: { | ||||
| 	title?: string | null; | ||||
| 	text?: string | null; | ||||
| 	placeholder?: string | null; | ||||
| 	autocomplete?: string; | ||||
| 	default?: string | null; | ||||
| 	minLength?: number; | ||||
| 	maxLength?: number; | ||||
| }): Promise<{ canceled: true; result: undefined; } | { | ||||
| 	canceled: false; result: string; | ||||
| }> { | ||||
| @@ -257,7 +260,10 @@ export function inputText(props: { | ||||
| 			input: { | ||||
| 				type: props.type, | ||||
| 				placeholder: props.placeholder, | ||||
| 				autocomplete: props.autocomplete, | ||||
| 				default: props.default, | ||||
| 				minLength: props.minLength, | ||||
| 				maxLength: props.maxLength, | ||||
| 			}, | ||||
| 		}, { | ||||
| 			done: result => { | ||||
| @@ -271,6 +277,7 @@ export function inputNumber(props: { | ||||
| 	title?: string | null; | ||||
| 	text?: string | null; | ||||
| 	placeholder?: string | null; | ||||
| 	autocomplete?: string; | ||||
| 	default?: number | null; | ||||
| }): Promise<{ canceled: true; result: undefined; } | { | ||||
| 	canceled: false; result: number; | ||||
| @@ -282,6 +289,7 @@ export function inputNumber(props: { | ||||
| 			input: { | ||||
| 				type: 'number', | ||||
| 				placeholder: props.placeholder, | ||||
| 				autocomplete: props.autocomplete, | ||||
| 				default: props.default, | ||||
| 			}, | ||||
| 		}, { | ||||
|   | ||||
							
								
								
									
										82
									
								
								packages/frontend/src/pages/settings/2fa.qrdialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								packages/frontend/src/pages/settings/2fa.qrdialog.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| <template> | ||||
| <MkModal | ||||
| 	ref="dialogEl" | ||||
| 	:prefer-type="'dialog'" | ||||
| 	:z-priority="'low'" | ||||
| 	@click="cancel" | ||||
| 	@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> | ||||
| 			<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.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> | ||||
| 	</div> | ||||
| </MkModal> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkModal from '@/components/MkModal.vue'; | ||||
| import MkKeyValue from '@/components/MkKeyValue.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | ||||
| defineProps<{ | ||||
| 	twoFactorData: { | ||||
| 		qr: string; | ||||
| 		url: string; | ||||
| 	}; | ||||
| }>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'ok'): void; | ||||
| 	(ev: 'cancel'): void; | ||||
| 	(ev: 'closed'): void; | ||||
| }>(); | ||||
|  | ||||
| const cancel = () => { | ||||
| 	emit('cancel'); | ||||
| 	emit('closed'); | ||||
| }; | ||||
|  | ||||
| const ok = () => { | ||||
| 	emit('ok'); | ||||
| 	emit('closed'); | ||||
| }; | ||||
| </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); | ||||
| } | ||||
|  | ||||
| .qr { | ||||
|     width: 20em; | ||||
|     max-width: 100%; | ||||
| } | ||||
| </style> | ||||
| @@ -1,183 +1,222 @@ | ||||
| <template> | ||||
| <div> | ||||
| 	<MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> | ||||
| 	<template v-if="$i.twoFactorEnabled"> | ||||
| 		<p>{{ i18n.ts._2fa.alreadyRegistered }}</p> | ||||
| 		<MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton> | ||||
| <FormSection :first="first"> | ||||
| 	<template #label>{{ i18n.ts['2fa'] }}</template> | ||||
|  | ||||
| 		<template v-if="supportsCredentials"> | ||||
| 			<hr class="totp-method-sep"> | ||||
|  | ||||
| 			<h2 class="heading">{{ i18n.ts.securityKey }}</h2> | ||||
| 			<p>{{ i18n.ts._2fa.securityKeyInfo }}</p> | ||||
| 			<div class="key-list"> | ||||
| 				<div v-for="key in $i.securityKeysList" class="key"> | ||||
| 					<h3>{{ key.name }}</h3> | ||||
| 					<div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> | ||||
| 					<MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> | ||||
| 				</div> | ||||
| 	<div v-if="$i" class="_gaps_s"> | ||||
| 		<MkFolder> | ||||
| 			<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> | ||||
| 			</div> | ||||
|  | ||||
| 			<MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch> | ||||
| 			<MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton> | ||||
| 		</MkFolder> | ||||
|  | ||||
| 			<MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> | ||||
| 			<MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> | ||||
| 		<MkFolder> | ||||
| 			<template #icon><i class="ti ti-key"></i></template> | ||||
| 			<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template> | ||||
| 			<div class="_gaps_s"> | ||||
| 				<MkInfo> | ||||
| 					{{ i18n.ts._2fa.securityKeyInfo }}<br> | ||||
| 					<br> | ||||
| 					{{ i18n.ts._2fa.chromePasskeyNotSupported }} | ||||
| 				</MkInfo> | ||||
|  | ||||
| 			<ol v-if="registration && !registration.error"> | ||||
| 				<li v-if="registration.stage >= 0"> | ||||
| 					{{ i18n.ts.tapSecurityKey }} | ||||
| 					<MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/> | ||||
| 				</li> | ||||
| 				<li v-if="registration.stage >= 1"> | ||||
| 					<MkForm :disabled="registration.stage != 1 || registration.saving"> | ||||
| 						<MkInput v-model="keyName" :max="30"> | ||||
| 							<template #label>{{ i18n.ts.securityKeyName }}</template> | ||||
| 						</MkInput> | ||||
| 						<MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> | ||||
| 						<MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> | ||||
| 					</MkForm> | ||||
| 				</li> | ||||
| 			</ol> | ||||
| 		</template> | ||||
| 	</template> | ||||
| 	<div v-if="twoFactorData && !$i.twoFactorEnabled"> | ||||
| 		<ol style="margin: 0; padding: 0 0 0 1em;"> | ||||
| 			<li> | ||||
| 				<I18n :src="i18n.ts._2fa.step1" tag="span"> | ||||
| 					<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> | ||||
| 			</li> | ||||
| 			<li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> | ||||
| 			<li> | ||||
| 				{{ i18n.ts._2fa.step3 }}<br> | ||||
| 				<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> | ||||
| 				<MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> | ||||
| 			</li> | ||||
| 		</ol> | ||||
| 		<MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo> | ||||
| 				<MkInfo v-if="!supportsCredentials" warn> | ||||
| 					{{ i18n.ts._2fa.securityKeyNotSupported }} | ||||
| 				</MkInfo> | ||||
|  | ||||
| 				<MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn> | ||||
| 					{{ i18n.ts._2fa.registerTOTPBeforeKey }} | ||||
| 				</MkInfo> | ||||
|  | ||||
| 				<template v-else> | ||||
| 					<MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton> | ||||
| 					<MkFolder v-for="key in $i.securityKeysList" :key="key.id"> | ||||
| 						<template #label>{{ key.name }}</template> | ||||
| 						<template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template> | ||||
| 						<div class="_buttons"> | ||||
| 							<MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton> | ||||
| 							<MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton> | ||||
| 						</div> | ||||
| </div> | ||||
| 					</MkFolder> | ||||
| 				</template> | ||||
| 			</div> | ||||
| 		</MkFolder> | ||||
|  | ||||
| 		<MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)"> | ||||
| 			<template #label>{{ i18n.ts.passwordLessLogin }}</template> | ||||
| 			<template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template> | ||||
| 		</MkSwitch> | ||||
| 	</div> | ||||
| </FormSection> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref } from 'vue'; | ||||
| import { ref, defineAsyncComponent } from 'vue'; | ||||
| import { hostname } from '@/config'; | ||||
| import { byteify, hexify, stringify } from '@/scripts/2fa'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import FormSection from '@/components/form/section.vue'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { $i } from '@/account'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | ||||
| // メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要 | ||||
|  | ||||
| withDefaults(defineProps<{ | ||||
| 	first?: boolean; | ||||
| }>(), { | ||||
| 	first: false, | ||||
| }); | ||||
|  | ||||
| const twoFactorData = ref<any>(null); | ||||
| const supportsCredentials = ref(!!navigator.credentials); | ||||
| const usePasswordLessLogin = ref($i!.usePasswordLessLogin); | ||||
| const registration = ref<any>(null); | ||||
| const keyName = ref(''); | ||||
| const token = ref(null); | ||||
| const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin); | ||||
|  | ||||
| function register() { | ||||
| 	os.inputText({ | ||||
| 		title: i18n.ts.password, | ||||
| async function registerTOTP() { | ||||
| 	const password = await os.inputText({ | ||||
| 		title: i18n.ts._2fa.registerTOTP, | ||||
| 		text: i18n.ts._2fa.passwordToTOTP, | ||||
| 		type: 'password', | ||||
| 	}).then(({ canceled, result: password }) => { | ||||
| 		if (canceled) return; | ||||
| 		os.api('i/2fa/register', { | ||||
| 			password: password, | ||||
| 		}).then(data => { | ||||
| 			twoFactorData.value = data; | ||||
| 		autocomplete: 'current-password', | ||||
| 	}); | ||||
| 	if (password.canceled) return; | ||||
|  | ||||
| 	const twoFactorData = await os.apiWithDialog('i/2fa/register', { | ||||
| 		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, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function unregister() { | ||||
| function unregisterTOTP() { | ||||
| 	os.inputText({ | ||||
| 		title: i18n.ts.password, | ||||
| 		type: 'password', | ||||
| 		autocomplete: 'current-password', | ||||
| 	}).then(({ canceled, result: password }) => { | ||||
| 		if (canceled) return; | ||||
| 		os.api('i/2fa/unregister', { | ||||
| 		os.apiWithDialog('i/2fa/unregister', { | ||||
| 			password: password, | ||||
| 		}).then(() => { | ||||
| 			usePasswordLessLogin.value = false; | ||||
| 			updatePasswordLessLogin(); | ||||
| 		}).then(() => { | ||||
| 			os.success(); | ||||
| 			$i!.twoFactorEnabled = false; | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function submit() { | ||||
| 	os.api('i/2fa/done', { | ||||
| 		token: token.value, | ||||
| 	}).then(() => { | ||||
| 		os.success(); | ||||
| 		$i!.twoFactorEnabled = true; | ||||
| 	}).catch(err => { | ||||
| 		}).catch(error => { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 			text: err, | ||||
| 				text: error, | ||||
| 			}); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function registerKey() { | ||||
| 	registration.value.saving = true; | ||||
| 	os.api('i/2fa/key-done', { | ||||
| 		password: registration.value.password, | ||||
| 		name: keyName.value, | ||||
| 		challengeId: registration.value.challengeId, | ||||
| 		// we convert each 16 bits to a string to serialise | ||||
| 		clientDataJSON: stringify(registration.value.credential.response.clientDataJSON), | ||||
| 		attestationObject: hexify(registration.value.credential.response.attestationObject), | ||||
| 	}).then(key => { | ||||
| 		registration.value = null; | ||||
| 		key.lastUsed = new Date(); | ||||
| 		os.success(); | ||||
| function renewTOTP() { | ||||
| 	os.confirm({ | ||||
| 		type: 'question', | ||||
| 		title: i18n.ts._2fa.renewTOTP, | ||||
| 		text: i18n.ts._2fa.renewTOTPConfirm, | ||||
| 		okText: i18n.ts._2fa.renewTOTPOk, | ||||
| 		cancelText: i18n.ts._2fa.renewTOTPCancel, | ||||
| 	}).then(({ canceled }) => { | ||||
| 		if (canceled) return; | ||||
| 		registerTOTP(); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function unregisterKey(key) { | ||||
| 	os.inputText({ | ||||
| async function unregisterKey(key) { | ||||
| 	const confirm = await os.confirm({ | ||||
| 		type: 'question', | ||||
| 		title: i18n.ts._2fa.removeKey, | ||||
| 		text: i18n.t('_2fa.removeKeyConfirm', { name: key.name }), | ||||
| 	}); | ||||
| 	if (confirm.canceled) return; | ||||
|  | ||||
| 	const password = await os.inputText({ | ||||
| 		title: i18n.ts.password, | ||||
| 		type: 'password', | ||||
| 	}).then(({ canceled, result: password }) => { | ||||
| 		if (canceled) return; | ||||
| 		return os.api('i/2fa/remove-key', { | ||||
| 			password, | ||||
| 		autocomplete: 'current-password', | ||||
| 	}); | ||||
| 	if (password.canceled) return; | ||||
|  | ||||
| 	await os.apiWithDialog('i/2fa/remove-key', { | ||||
| 		password: password.result, | ||||
| 		credentialId: key.id, | ||||
| 		}).then(() => { | ||||
| 			usePasswordLessLogin.value = false; | ||||
| 			updatePasswordLessLogin(); | ||||
| 		}).then(() => { | ||||
| 			os.success(); | ||||
| 	}); | ||||
| 	os.success(); | ||||
| } | ||||
|  | ||||
| async function renameKey(key) { | ||||
| 	const name = await os.inputText({ | ||||
| 		title: i18n.ts.rename, | ||||
| 		default: key.name, | ||||
| 		type: 'text', | ||||
| 		minLength: 1, | ||||
| 		maxLength: 30, | ||||
| 	}); | ||||
| 	if (name.canceled) return; | ||||
|  | ||||
| 	await os.apiWithDialog('i/2fa/update-key', { | ||||
| 		name: name.result, | ||||
| 		credentialId: key.id, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function addSecurityKey() { | ||||
| 	os.inputText({ | ||||
| async function addSecurityKey() { | ||||
| 	const password = await os.inputText({ | ||||
| 		title: i18n.ts.password, | ||||
| 		type: 'password', | ||||
| 	}).then(({ canceled, result: password }) => { | ||||
| 		if (canceled) return; | ||||
| 		os.api('i/2fa/register-key', { | ||||
| 			password, | ||||
| 		}).then(reg => { | ||||
| 			registration.value = { | ||||
| 				password, | ||||
| 				challengeId: reg!.challengeId, | ||||
| 				stage: 0, | ||||
| 				publicKeyOptions: { | ||||
| 					challenge: byteify(reg!.challenge, 'base64'), | ||||
| 		autocomplete: 'current-password', | ||||
| 	}); | ||||
| 	if (password.canceled) return; | ||||
|  | ||||
| 	const challenge: any = await os.apiWithDialog('i/2fa/register-key', { | ||||
| 		password: password.result, | ||||
| 	}); | ||||
|  | ||||
| 	const name = await os.inputText({ | ||||
| 		title: i18n.ts._2fa.registerSecurityKey, | ||||
| 		text: i18n.ts._2fa.securityKeyName, | ||||
| 		type: 'text', | ||||
| 		minLength: 1, | ||||
| 		maxLength: 30, | ||||
| 	}); | ||||
| 	if (name.canceled) return; | ||||
|  | ||||
| 	const webAuthnCreation = navigator.credentials.create({ | ||||
| 		publicKey: { | ||||
| 			challenge: byteify(challenge.challenge, 'base64'), | ||||
| 			rp: { | ||||
| 				id: hostname, | ||||
| 				name: 'Misskey', | ||||
| @@ -191,26 +230,29 @@ function addSecurityKey() { | ||||
| 			timeout: 60000, | ||||
| 			attestation: 'direct', | ||||
| 		}, | ||||
| 				saving: true, | ||||
| 			}; | ||||
| 			return navigator.credentials.create({ | ||||
| 				publicKey: registration.value.publicKeyOptions, | ||||
| 			}); | ||||
| 		}).then(credential => { | ||||
| 			registration.value.credential = credential; | ||||
| 			registration.value.saving = false; | ||||
| 			registration.value.stage = 1; | ||||
| 		}).catch(err => { | ||||
| 			console.warn('Error while registering?', err); | ||||
| 			registration.value.error = err.message; | ||||
| 			registration.value.stage = -1; | ||||
| 		}); | ||||
| 	}) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>; | ||||
|  | ||||
| 	const credential = await os.promiseDialog( | ||||
| 		webAuthnCreation, | ||||
| 		null, | ||||
| 		() => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない | ||||
| 		i18n.ts._2fa.tapSecurityKey, | ||||
| 	); | ||||
| 	if (!credential) return; | ||||
|  | ||||
| 	await os.apiWithDialog('i/2fa/key-done', { | ||||
| 		password: password.result, | ||||
| 		name: name.result, | ||||
| 		challengeId: challenge.challengeId, | ||||
| 		// we convert each 16 bits to a string to serialise | ||||
| 		clientDataJSON: stringify(credential.response.clientDataJSON), | ||||
| 		attestationObject: hexify(credential.response.attestationObject), | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function updatePasswordLessLogin() { | ||||
| 	await os.api('i/2fa/password-less', { | ||||
| 		value: !!usePasswordLessLogin.value, | ||||
| async function updatePasswordLessLogin(value: boolean) { | ||||
| 	await os.apiWithDialog('i/2fa/password-less', { | ||||
| 		value, | ||||
| 	}); | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -5,10 +5,7 @@ | ||||
| 		<MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton> | ||||
| 	</FormSection> | ||||
|  | ||||
| 	<FormSection> | ||||
| 		<template #label>{{ i18n.ts.twoStepAuthentication }}</template> | ||||
| 	<X2fa/> | ||||
| 	</FormSection> | ||||
|  | ||||
| 	<FormSection> | ||||
| 		<template #label>{{ i18n.ts.signinHistory }}</template> | ||||
| @@ -56,18 +53,21 @@ async function change() { | ||||
| 	const { canceled: canceled1, result: currentPassword } = await os.inputText({ | ||||
| 		title: i18n.ts.currentPassword, | ||||
| 		type: 'password', | ||||
| 		autocomplete: 'current-password', | ||||
| 	}); | ||||
| 	if (canceled1) return; | ||||
|  | ||||
| 	const { canceled: canceled2, result: newPassword } = await os.inputText({ | ||||
| 		title: i18n.ts.newPassword, | ||||
| 		type: 'password', | ||||
| 		autocomplete: 'new-password', | ||||
| 	}); | ||||
| 	if (canceled2) return; | ||||
|  | ||||
| 	const { canceled: canceled3, result: newPassword2 } = await os.inputText({ | ||||
| 		title: i18n.ts.newPasswordRetype, | ||||
| 		type: 'password', | ||||
| 		autocomplete: 'new-password', | ||||
| 	}); | ||||
| 	if (canceled3) return; | ||||
|  | ||||
| @@ -109,7 +109,7 @@ definePageMetadata({ | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .timnmucd { | ||||
| 	padding: 16px; | ||||
| 	padding: 12px; | ||||
|  | ||||
| 	&:first-child { | ||||
| 		border-top-left-radius: 6px; | ||||
|   | ||||
							
								
								
									
										39
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -103,7 +103,6 @@ importers: | ||||
|       '@types/semver': 7.3.13 | ||||
|       '@types/sharp': 0.31.1 | ||||
|       '@types/sinonjs__fake-timers': 8.1.2 | ||||
|       '@types/speakeasy': 2.0.7 | ||||
|       '@types/tinycolor2': 1.4.3 | ||||
|       '@types/tmp': 0.2.3 | ||||
|       '@types/unzipper': 0.10.5 | ||||
| @@ -164,6 +163,7 @@ importers: | ||||
|       nsfwjs: 2.4.2 | ||||
|       oauth: 0.10.0 | ||||
|       os-utils: 0.0.14 | ||||
|       otpauth: ^9.0.2 | ||||
|       parse5: 7.1.2 | ||||
|       pg: 8.9.0 | ||||
|       private-ip: 3.0.0 | ||||
| @@ -187,7 +187,6 @@ importers: | ||||
|       seedrandom: 3.0.5 | ||||
|       semver: 7.3.8 | ||||
|       sharp: 0.31.3 | ||||
|       speakeasy: 2.0.0 | ||||
|       strict-event-emitter-types: 2.0.0 | ||||
|       stringz: 2.1.0 | ||||
|       summaly: github:misskey-dev/summaly | ||||
| @@ -268,6 +267,7 @@ importers: | ||||
|       nsfwjs: 2.4.2_@tensorflow+tfjs@4.2.0 | ||||
|       oauth: 0.10.0 | ||||
|       os-utils: 0.0.14 | ||||
|       otpauth: 9.0.2 | ||||
|       parse5: 7.1.2 | ||||
|       pg: 8.9.0 | ||||
|       private-ip: 3.0.0 | ||||
| @@ -291,10 +291,9 @@ importers: | ||||
|       seedrandom: 3.0.5 | ||||
|       semver: 7.3.8 | ||||
|       sharp: 0.31.3 | ||||
|       speakeasy: 2.0.0 | ||||
|       strict-event-emitter-types: 2.0.0 | ||||
|       stringz: 2.1.0 | ||||
|       summaly: github.com/misskey-dev/summaly/5684f116c92f1fd122badc7aee062494bdb43b36 | ||||
|       summaly: github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494 | ||||
|       systeminformation: 5.17.8 | ||||
|       tinycolor2: 1.6.0 | ||||
|       tmp: 0.2.1 | ||||
| @@ -352,7 +351,6 @@ importers: | ||||
|       '@types/semver': 7.3.13 | ||||
|       '@types/sharp': 0.31.1 | ||||
|       '@types/sinonjs__fake-timers': 8.1.2 | ||||
|       '@types/speakeasy': 2.0.7 | ||||
|       '@types/tinycolor2': 1.4.3 | ||||
|       '@types/tmp': 0.2.3 | ||||
|       '@types/unzipper': 0.10.5 | ||||
| @@ -2801,12 +2799,6 @@ packages: | ||||
|     resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==} | ||||
|     dev: true | ||||
|  | ||||
|   /@types/speakeasy/2.0.7: | ||||
|     resolution: {integrity: sha512-JEcOhN2SQCoX86ZfiZEe8px84sVJtivBXMZfOVyARTYEj0hrwwbj1nF0FwEL3nJSoEV6uTbcdLllMKBgAYHWCQ==} | ||||
|     dependencies: | ||||
|       '@types/node': 18.13.0 | ||||
|     dev: true | ||||
|  | ||||
|   /@types/stack-utils/2.0.1: | ||||
|     resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} | ||||
|     dev: true | ||||
| @@ -3849,10 +3841,6 @@ packages: | ||||
|       pascalcase: 0.1.1 | ||||
|     dev: false | ||||
|  | ||||
|   /base32.js/0.0.1: | ||||
|     resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==} | ||||
|     dev: false | ||||
|  | ||||
|   /base64-js/1.5.1: | ||||
|     resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} | ||||
|  | ||||
| @@ -8686,6 +8674,10 @@ packages: | ||||
|     resolution: {integrity: sha512-emiQ05haY9CRj1Ho/LiuCqr/+8RgJuWdiHYNglIg2Qjfz0n+pnUq9I2QHplXuOMO2EnAW1oCGC1++aU5VoWSlw==} | ||||
|     dev: false | ||||
|  | ||||
|   /jssha/3.3.0: | ||||
|     resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==} | ||||
|     dev: false | ||||
|  | ||||
|   /jstransformer/1.0.0: | ||||
|     resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} | ||||
|     dependencies: | ||||
| @@ -9870,6 +9862,12 @@ packages: | ||||
|     resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} | ||||
|     dev: true | ||||
|  | ||||
|   /otpauth/9.0.2: | ||||
|     resolution: {integrity: sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==} | ||||
|     dependencies: | ||||
|       jssha: 3.3.0 | ||||
|     dev: false | ||||
|  | ||||
|   /p-cancelable/2.1.1: | ||||
|     resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} | ||||
|     engines: {node: '>=8'} | ||||
| @@ -11785,13 +11783,6 @@ packages: | ||||
|     resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} | ||||
|     dev: false | ||||
|  | ||||
|   /speakeasy/2.0.0: | ||||
|     resolution: {integrity: sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==} | ||||
|     engines: {node: '>= 0.10.0'} | ||||
|     dependencies: | ||||
|       base32.js: 0.0.1 | ||||
|     dev: false | ||||
|  | ||||
|   /split-string/3.1.0: | ||||
|     resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} | ||||
|     engines: {node: '>=0.10.0'} | ||||
| @@ -13445,8 +13436,8 @@ packages: | ||||
|     version: 2.2.1-misskey.3 | ||||
|     dev: false | ||||
|  | ||||
|   github.com/misskey-dev/summaly/5684f116c92f1fd122badc7aee062494bdb43b36: | ||||
|     resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/5684f116c92f1fd122badc7aee062494bdb43b36} | ||||
|   github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494: | ||||
|     resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/51f3870e1ff5e0b22102e804112b10cb72f3c494} | ||||
|     name: summaly | ||||
|     version: 3.0.4 | ||||
|     dependencies: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 tamaina
					tamaina