enhance(frontend): サインイン画面の改善 (#14658)
* wip * Update MkSignin.vue * Update MkSignin.vue * wip * Update CHANGELOG.md * enhance(frontend): サインイン画面の改善 * Update Changelog * 14655の変更取り込み * spdx * fix * fix * fix * 🎨 * 🎨 * 🎨 * 🎨 * Captchaがリセットされない問題を修正 * 次の処理をsignin apiから読み取るように * Add Comments * fix * fix test * attempt to fix test * fix test * fix test * fix test * fix * fix test * fix: 一部のエラーがちゃんと出るように * Update Changelog * 🎨 * 🎨 * remove border --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										206
									
								
								packages/frontend/src/components/MkSignin.input.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								packages/frontend/src/components/MkSignin.input.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,206 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and misskey-project | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <div :class="$style.wrapper" data-cy-signin-page-input> | ||||
| 	<div :class="$style.root"> | ||||
| 		<div :class="$style.avatar"> | ||||
| 			<i class="ti ti-user"></i> | ||||
| 		</div> | ||||
|  | ||||
| 		<!-- ログイン画面メッセージ --> | ||||
| 		<MkInfo v-if="message"> | ||||
| 			{{ message }} | ||||
| 		</MkInfo> | ||||
|  | ||||
| 		<!-- 外部サーバーへの転送 --> | ||||
| 		<div v-if="openOnRemote" class="_gaps_m"> | ||||
| 			<div class="_gaps_s"> | ||||
| 				<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)"> | ||||
| 					{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i> | ||||
| 				</MkButton> | ||||
| 				<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)"> | ||||
| 					{{ i18n.ts.specifyServerHost }} | ||||
| 				</button> | ||||
| 			</div> | ||||
| 			<div :class="$style.orHr"> | ||||
| 				<p :class="$style.orMsg">{{ i18n.ts.or }}</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<!-- username入力 --> | ||||
| 		<form class="_gaps_s" @submit.prevent="emit('usernameSubmitted', username)"> | ||||
| 			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username> | ||||
| 				<template #prefix>@</template> | ||||
| 				<template #suffix>@{{ host }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkButton type="submit" large primary rounded style="margin: 0 auto;" data-cy-signin-page-input-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> | ||||
| 		</form> | ||||
|  | ||||
| 		<!-- パスワードレスログイン --> | ||||
| 		<div :class="$style.orHr"> | ||||
| 			<p :class="$style.orMsg">{{ i18n.ts.or }}</p> | ||||
| 		</div> | ||||
| 		<div> | ||||
| 			<MkButton type="submit" style="margin: auto auto;" large rounded primary gradate @click="emit('passkeyClick', $event)"> | ||||
| 				<i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }} | ||||
| 			</MkButton> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref } from 'vue'; | ||||
| import { toUnicode } from 'punycode/'; | ||||
|  | ||||
| import { query, extractDomain } from '@@/js/url.js'; | ||||
| import { host as configHost } from '@@/js/config.js'; | ||||
| import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import * as os from '@/os.js'; | ||||
|  | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	message?: string, | ||||
| 	openOnRemote?: OpenOnRemoteOptions, | ||||
| }>(), { | ||||
| 	message: '', | ||||
| 	openOnRemote: undefined, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'usernameSubmitted', v: string): void; | ||||
| 	(ev: 'passkeyClick', v: MouseEvent): void; | ||||
| }>(); | ||||
|  | ||||
| const host = toUnicode(configHost); | ||||
|  | ||||
| const username = ref(''); | ||||
|  | ||||
| //#region Open on remote | ||||
| function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { | ||||
| 	switch (options.type) { | ||||
| 		case 'web': | ||||
| 		case 'lookup': { | ||||
| 			let _path: string; | ||||
|  | ||||
| 			if (options.type === 'lookup') { | ||||
| 				// TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼ | ||||
| 				// _path = `/lookup?uri=${encodeURIComponent(_path)}`; | ||||
| 				_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`; | ||||
| 			} else { | ||||
| 				_path = options.path; | ||||
| 			} | ||||
|  | ||||
| 			if (targetHost) { | ||||
| 				window.open(`https://${targetHost}${_path}`, '_blank', 'noopener'); | ||||
| 			} else { | ||||
| 				window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener'); | ||||
| 			} | ||||
| 			break; | ||||
| 		} | ||||
| 		case 'share': { | ||||
| 			const params = query(options.params); | ||||
| 			if (targetHost) { | ||||
| 				window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener'); | ||||
| 			} else { | ||||
| 				window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener'); | ||||
| 			} | ||||
| 			break; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> { | ||||
| 	const { canceled, result: hostTemp } = await os.inputText({ | ||||
| 		title: i18n.ts.inputHostName, | ||||
| 		placeholder: 'misskey.example.com', | ||||
| 	}); | ||||
|  | ||||
| 	if (canceled) return; | ||||
|  | ||||
| 	let targetHost: string | null = hostTemp; | ||||
|  | ||||
| 	// ドメイン部分だけを取り出す | ||||
| 	targetHost = extractDomain(targetHost ?? ''); | ||||
| 	if (targetHost == null) { | ||||
| 		os.alert({ | ||||
| 			type: 'error', | ||||
| 			title: i18n.ts.invalidValue, | ||||
| 			text: i18n.ts.tryAgain, | ||||
| 		}); | ||||
| 		return; | ||||
| 	} | ||||
| 	openRemote(options, targetHost); | ||||
| } | ||||
| //#endregion | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	display: flex; | ||||
| 	flex-direction: column; | ||||
| 	gap: 20px; | ||||
| } | ||||
|  | ||||
| .wrapper { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	width: 100%; | ||||
| 	min-height: 336px; | ||||
|  | ||||
| 	> .root { | ||||
| 		width: 100%; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .avatar { | ||||
| 	margin: 0 auto; | ||||
| 	background-color: color-mix(in srgb, var(--fg), transparent 85%); | ||||
| 	color: color-mix(in srgb, var(--fg), transparent 25%); | ||||
| 	text-align: center; | ||||
| 	height: 64px; | ||||
| 	width: 64px; | ||||
| 	font-size: 24px; | ||||
| 	line-height: 64px; | ||||
| 	border-radius: 50%; | ||||
| } | ||||
|  | ||||
| .instanceManualSelectButton { | ||||
| 	display: block; | ||||
| 	text-align: center; | ||||
| 	opacity: .7; | ||||
| 	font-size: .8em; | ||||
|  | ||||
| 	&:hover { | ||||
| 		text-decoration: underline; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .orHr { | ||||
| 	position: relative; | ||||
| 	margin: .4em auto; | ||||
| 	width: 100%; | ||||
| 	height: 1px; | ||||
| 	background: var(--divider); | ||||
| } | ||||
|  | ||||
| .orMsg { | ||||
| 	position: absolute; | ||||
| 	top: -.6em; | ||||
| 	display: inline-block; | ||||
| 	padding: 0 1em; | ||||
| 	background: var(--panel); | ||||
| 	font-size: 0.8em; | ||||
| 	color: var(--fgOnPanel); | ||||
| 	margin: 0; | ||||
| 	left: 50%; | ||||
| 	transform: translateX(-50%); | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										92
									
								
								packages/frontend/src/components/MkSignin.passkey.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								packages/frontend/src/components/MkSignin.passkey.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and misskey-project | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <div :class="$style.wrapper"> | ||||
| 	<div class="_gaps" :class="$style.root"> | ||||
| 		<div class="_gaps_s"> | ||||
| 			<div :class="$style.passkeyIcon"> | ||||
| 				<i class="ti ti-fingerprint"></i> | ||||
| 			</div> | ||||
| 			<div :class="$style.passkeyDescription">{{ i18n.ts.useSecurityKey }}</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<MkButton large primary rounded :disabled="queryingKey" style="margin: 0 auto;" @click="queryKey">{{ i18n.ts.retry }}</MkButton> | ||||
|  | ||||
| 		<MkButton v-if="isPerformingPasswordlessLogin !== true" transparent rounded :disabled="queryingKey" style="margin: 0 auto;" @click="emit('useTotp')">{{ i18n.ts.useTotp }}</MkButton> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, onMounted } from 'vue'; | ||||
| import { get as webAuthnRequest } from '@github/webauthn-json/browser-ponyfill'; | ||||
|  | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
|  | ||||
| import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	credentialRequest: CredentialRequestOptions; | ||||
| 	isPerformingPasswordlessLogin?: boolean; | ||||
| }>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'done', credential: AuthenticationPublicKeyCredential): void; | ||||
| 	(ev: 'useTotp'): void; | ||||
| }>(); | ||||
|  | ||||
| const queryingKey = ref(true); | ||||
|  | ||||
| async function queryKey() { | ||||
| 	queryingKey.value = true; | ||||
| 	await webAuthnRequest(props.credentialRequest) | ||||
| 		.catch(() => { | ||||
| 			return Promise.reject(null); | ||||
| 		}) | ||||
| 		.then((credential) => { | ||||
| 			emit('done', credential); | ||||
| 		}) | ||||
| 		.finally(() => { | ||||
| 			queryingKey.value = false; | ||||
| 		}); | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| 	queryKey(); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .wrapper { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	width: 100%; | ||||
| 	min-height: 336px; | ||||
|  | ||||
| 	> .root { | ||||
| 		width: 100%; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .passkeyIcon { | ||||
| 	margin: 0 auto; | ||||
| 	background-color: var(--accentedBg); | ||||
| 	color: var(--accent); | ||||
| 	text-align: center; | ||||
| 	height: 64px; | ||||
| 	width: 64px; | ||||
| 	font-size: 24px; | ||||
| 	line-height: 64px; | ||||
| 	border-radius: 50%; | ||||
| } | ||||
|  | ||||
| .passkeyDescription { | ||||
| 	text-align: center; | ||||
| 	font-size: 1.1em; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										181
									
								
								packages/frontend/src/components/MkSignin.password.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								packages/frontend/src/components/MkSignin.password.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,181 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and misskey-project | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <div :class="$style.wrapper" data-cy-signin-page-password> | ||||
| 	<div class="_gaps" :class="$style.root"> | ||||
| 		<div :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined }"></div> | ||||
| 		<div :class="$style.welcomeBackMessage"> | ||||
| 			<I18n :src="i18n.ts.welcomeBackWithName" tag="span"> | ||||
| 				<template #name><Mfm :text="user.name ?? user.username" :plain="true"/></template> | ||||
| 			</I18n> | ||||
| 		</div> | ||||
|  | ||||
| 		<!-- password入力 --> | ||||
| 		<form class="_gaps_s" @submit.prevent="onSubmit"> | ||||
| 			<!-- ブラウザ オートコンプリート用 --> | ||||
| 			<input type="hidden" name="username" autocomplete="username" :value="user.username"> | ||||
|  | ||||
| 			<MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required autofocus 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> | ||||
|  | ||||
| 			<div v-if="needCaptcha"> | ||||
| 				<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> | ||||
| 				<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> | ||||
| 				<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> | ||||
| 				<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> | ||||
| 			</div> | ||||
|  | ||||
| 			<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> | ||||
| 		</form> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| export type PwResponse = { | ||||
| 	password: string; | ||||
| 	captcha: { | ||||
| 		hCaptchaResponse: string | null; | ||||
| 		mCaptchaResponse: string | null; | ||||
| 		reCaptchaResponse: string | null; | ||||
| 		turnstileResponse: string | null; | ||||
| 	}; | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, computed, useTemplateRef, defineAsyncComponent } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
|  | ||||
| import { instance } from '@/instance.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import * as os from '@/os.js'; | ||||
|  | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkCaptcha from '@/components/MkCaptcha.vue'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	user: Misskey.entities.UserDetailed; | ||||
| 	needCaptcha: boolean; | ||||
| }>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'passwordSubmitted', v: PwResponse): void; | ||||
| }>(); | ||||
|  | ||||
| const password = ref(''); | ||||
|  | ||||
| const hCaptcha = useTemplateRef('hcaptcha'); | ||||
| const mCaptcha = useTemplateRef('mcaptcha'); | ||||
| const reCaptcha = useTemplateRef('recaptcha'); | ||||
| const turnstile = useTemplateRef('turnstile'); | ||||
|  | ||||
| const hCaptchaResponse = ref<string | null>(null); | ||||
| const mCaptchaResponse = ref<string | null>(null); | ||||
| const reCaptchaResponse = ref<string | null>(null); | ||||
| const turnstileResponse = ref<string | null>(null); | ||||
|  | ||||
| const captchaFailed = computed((): boolean => { | ||||
| 	return ( | ||||
| 		(instance.enableHcaptcha && !hCaptchaResponse.value) || | ||||
| 		(instance.enableMcaptcha && !mCaptchaResponse.value) || | ||||
| 		(instance.enableRecaptcha && !reCaptchaResponse.value) || | ||||
| 		(instance.enableTurnstile && !turnstileResponse.value) | ||||
| 	); | ||||
| }); | ||||
|  | ||||
| function resetPassword(): void { | ||||
| 	const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { | ||||
| 		closed: () => dispose(), | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function onSubmit() { | ||||
| 	emit('passwordSubmitted', { | ||||
| 		password: password.value, | ||||
| 		captcha: { | ||||
| 			hCaptchaResponse: hCaptchaResponse.value, | ||||
| 			mCaptchaResponse: mCaptchaResponse.value, | ||||
| 			reCaptchaResponse: reCaptchaResponse.value, | ||||
| 			turnstileResponse: turnstileResponse.value, | ||||
| 		}, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function resetCaptcha() { | ||||
| 	hCaptcha.value?.reset(); | ||||
| 	mCaptcha.value?.reset(); | ||||
| 	reCaptcha.value?.reset(); | ||||
| 	turnstile.value?.reset(); | ||||
| } | ||||
|  | ||||
| defineExpose({ | ||||
| 	resetCaptcha, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .wrapper { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	width: 100%; | ||||
| 	min-height: 336px; | ||||
|  | ||||
| 	> .root { | ||||
| 		width: 100%; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .avatar { | ||||
| 	margin: 0 auto 0 auto; | ||||
| 	width: 64px; | ||||
| 	height: 64px; | ||||
| 	background: #ddd; | ||||
| 	background-position: center; | ||||
| 	background-size: cover; | ||||
| 	border-radius: 100%; | ||||
| } | ||||
|  | ||||
| .welcomeBackMessage { | ||||
| 	text-align: center; | ||||
| 	font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .instanceManualSelectButton { | ||||
| 	display: block; | ||||
| 	text-align: center; | ||||
| 	opacity: .7; | ||||
| 	font-size: .8em; | ||||
|  | ||||
| 	&:hover { | ||||
| 		text-decoration: underline; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .orHr { | ||||
| 	position: relative; | ||||
| 	margin: .4em auto; | ||||
| 	width: 100%; | ||||
| 	height: 1px; | ||||
| 	background: var(--divider); | ||||
| } | ||||
|  | ||||
| .orMsg { | ||||
| 	position: absolute; | ||||
| 	top: -.6em; | ||||
| 	display: inline-block; | ||||
| 	padding: 0 1em; | ||||
| 	background: var(--panel); | ||||
| 	font-size: 0.8em; | ||||
| 	color: var(--fgOnPanel); | ||||
| 	margin: 0; | ||||
| 	left: 50%; | ||||
| 	transform: translateX(-50%); | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										74
									
								
								packages/frontend/src/components/MkSignin.totp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								packages/frontend/src/components/MkSignin.totp.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and misskey-project | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <div :class="$style.wrapper"> | ||||
| 	<div class="_gaps" :class="$style.root"> | ||||
| 		<div class="_gaps_s"> | ||||
| 			<div :class="$style.totpIcon"> | ||||
| 				<i class="ti ti-key"></i> | ||||
| 			</div> | ||||
| 			<div :class="$style.totpDescription">{{ i18n.ts['2fa'] }}</div> | ||||
| 		</div> | ||||
|  | ||||
| 		<!-- totp入力 --> | ||||
| 		<form class="_gaps_s" @submit.prevent="emit('totpSubmitted', token)"> | ||||
| 			<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required autofocus :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'"> | ||||
| 				<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template> | ||||
| 				<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template> | ||||
| 				<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template> | ||||
| 			</MkInput> | ||||
|  | ||||
| 			<MkButton type="submit" large primary rounded style="margin: 0 auto;">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> | ||||
| 		</form> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref } from 'vue'; | ||||
|  | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'totpSubmitted', token: string): void; | ||||
| }>(); | ||||
|  | ||||
| const token = ref(''); | ||||
| const isBackupCode = ref(false); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .wrapper { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	width: 100%; | ||||
| 	min-height: 336px; | ||||
|  | ||||
| 	> .root { | ||||
| 		width: 100%; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .totpIcon { | ||||
| 	margin: 0 auto; | ||||
| 	background-color: var(--accentedBg); | ||||
| 	color: var(--accent); | ||||
| 	text-align: center; | ||||
| 	height: 64px; | ||||
| 	width: 64px; | ||||
| 	font-size: 24px; | ||||
| 	line-height: 64px; | ||||
| 	border-radius: 50%; | ||||
| } | ||||
|  | ||||
| .totpDescription { | ||||
| 	text-align: center; | ||||
| 	font-size: 1.1em; | ||||
| } | ||||
| </style> | ||||
| @@ -4,438 +4,402 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> | ||||
| 	<div class="_gaps_m"> | ||||
| 		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div> | ||||
| 		<MkInfo v-if="message"> | ||||
| 			{{ message }} | ||||
| 		</MkInfo> | ||||
| 		<div v-if="openOnRemote" class="_gaps_m"> | ||||
| 			<div class="_gaps_s"> | ||||
| 				<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)"> | ||||
| 					{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i> | ||||
| 				</MkButton> | ||||
| 				<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)"> | ||||
| 					{{ i18n.ts.specifyServerHost }} | ||||
| 				</button> | ||||
| 			</div> | ||||
| 			<div :class="$style.orHr"> | ||||
| 				<p :class="$style.orMsg">{{ i18n.ts.or }}</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div v-if="!totpLogin" class="normal-signin _gaps_m"> | ||||
| 			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> | ||||
| 				<template #prefix>@</template> | ||||
| 				<template #suffix>@{{ host }}</template> | ||||
| 			</MkInput> | ||||
| 			<MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="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> | ||||
| 			<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> | ||||
| 			<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> | ||||
| 			<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> | ||||
| 			<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> | ||||
| 			<MkButton type="submit" large primary rounded :disabled="captchaFailed || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> | ||||
| 		</div> | ||||
| 		<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> | ||||
| 			<div v-if="user && user.securityKeys" class="twofa-group tap-group"> | ||||
| 				<p>{{ i18n.ts.useSecurityKey }}</p> | ||||
| 				<MkButton v-if="!queryingKey" @click="query2FaKey"> | ||||
| 					{{ i18n.ts.retry }} | ||||
| 				</MkButton> | ||||
| 			</div> | ||||
| 			<div v-if="user && user.securityKeys" :class="$style.orHr"> | ||||
| 				<p :class="$style.orMsg">{{ i18n.ts.or }}</p> | ||||
| 			</div> | ||||
| 			<div class="twofa-group totp-group _gaps"> | ||||
| 				<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'"> | ||||
| 					<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template> | ||||
| 					<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template> | ||||
| 					<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template> | ||||
| 				</MkInput> | ||||
| 				<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div v-if="!totpLogin && usePasswordLessLogin" :class="$style.orHr"> | ||||
| 			<p :class="$style.orMsg">{{ i18n.ts.or }}</p> | ||||
| 		</div> | ||||
| 		<div v-if="!totpLogin && usePasswordLessLogin" class="twofa-group tap-group"> | ||||
| 			<MkButton v-if="!queryingKey" type="submit" :disabled="signing" style="margin: auto auto;" rounded large primary @click="onPasskeyLogin"> | ||||
| 				<i class="ti ti-device-usb" style="font-size: medium;"></i> | ||||
| 				{{ signing ? i18n.ts.loggingIn : i18n.ts.signinWithPasskey }} | ||||
| 			</MkButton> | ||||
| 			<p v-if="queryingKey">{{ i18n.ts.useSecurityKey }}</p> | ||||
| 		</div> | ||||
| <div :class="$style.signinRoot"> | ||||
| 	<Transition | ||||
| 		mode="out-in" | ||||
| 		:enterActiveClass="$style.transition_enterActive" | ||||
| 		:leaveActiveClass="$style.transition_leaveActive" | ||||
| 		:enterFromClass="$style.transition_enterFrom" | ||||
| 		:leaveToClass="$style.transition_leaveTo" | ||||
|  | ||||
| 		:inert="waiting" | ||||
| 	> | ||||
| 		<!-- 1. 外部サーバーへの転送・username入力・パスキー --> | ||||
| 		<XInput | ||||
| 			v-if="page === 'input'" | ||||
| 			key="input" | ||||
| 			:message="message" | ||||
| 			:openOnRemote="openOnRemote" | ||||
|  | ||||
| 			@usernameSubmitted="onUsernameSubmitted" | ||||
| 			@passkeyClick="onPasskeyLogin" | ||||
| 		/> | ||||
|  | ||||
| 		<!-- 2. パスワード入力 --> | ||||
| 		<XPassword | ||||
| 			v-else-if="page === 'password'" | ||||
| 			key="password" | ||||
| 			ref="passwordPageEl" | ||||
|  | ||||
| 			:user="userInfo!" | ||||
| 			:needCaptcha="needCaptcha" | ||||
|  | ||||
| 			@passwordSubmitted="onPasswordSubmitted" | ||||
| 		/> | ||||
|  | ||||
| 		<!-- 3. ワンタイムパスワード --> | ||||
| 		<XTotp | ||||
| 			v-else-if="page === 'totp'" | ||||
| 			key="totp" | ||||
|  | ||||
| 			@totpSubmitted="onTotpSubmitted" | ||||
| 		/> | ||||
|  | ||||
| 		<!-- 4. パスキー --> | ||||
| 		<XPasskey | ||||
| 			v-else-if="page === 'passkey'" | ||||
| 			key="passkey" | ||||
|  | ||||
| 			:credentialRequest="credentialRequest!" | ||||
| 			:isPerformingPasswordlessLogin="doingPasskeyFromInputPage" | ||||
|  | ||||
| 			@done="onPasskeyDone" | ||||
| 			@useTotp="onUseTotp" | ||||
| 		/> | ||||
| 	</Transition> | ||||
| 	<div v-if="waiting" :class="$style.waitingRoot"> | ||||
| 		<MkLoading/> | ||||
| 	</div> | ||||
| </form> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { computed, defineAsyncComponent, ref } from 'vue'; | ||||
| import { toUnicode } from 'punycode/'; | ||||
| <script setup lang="ts"> | ||||
| import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; | ||||
| import { query, extractDomain } from '@@/js/url.js'; | ||||
| import { host as configHost } from '@@/js/config.js'; | ||||
| import MkDivider from './MkDivider.vue'; | ||||
| import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; | ||||
| import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; | ||||
|  | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; | ||||
| import { login } from '@/account.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { instance } from '@/instance.js'; | ||||
| import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; | ||||
| import * as os from '@/os.js'; | ||||
|  | ||||
| const signing = ref(false); | ||||
| const user = ref<Misskey.entities.UserDetailed | null>(null); | ||||
| const usePasswordLessLogin = ref<Misskey.entities.UserDetailed['usePasswordLessLogin']>(true); | ||||
| const username = ref(''); | ||||
| const password = ref(''); | ||||
| const token = ref(''); | ||||
| const host = ref(toUnicode(configHost)); | ||||
| const totpLogin = ref(false); | ||||
| const isBackupCode = ref(false); | ||||
| const queryingKey = ref(false); | ||||
| let credentialRequest: CredentialRequestOptions | null = null; | ||||
| const passkey_context = ref(''); | ||||
| const hcaptcha = ref<Captcha | undefined>(); | ||||
| const mcaptcha = ref<Captcha | undefined>(); | ||||
| const recaptcha = ref<Captcha | undefined>(); | ||||
| const turnstile = ref<Captcha | undefined>(); | ||||
| const hCaptchaResponse = ref<string | null>(null); | ||||
| const mCaptchaResponse = ref<string | null>(null); | ||||
| const reCaptchaResponse = ref<string | null>(null); | ||||
| const turnstileResponse = ref<string | null>(null); | ||||
| import XInput from '@/components/MkSignin.input.vue'; | ||||
| import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue'; | ||||
| import XTotp from '@/components/MkSignin.totp.vue'; | ||||
| import XPasskey from '@/components/MkSignin.passkey.vue'; | ||||
|  | ||||
| const captchaFailed = computed((): boolean => { | ||||
| 	return ( | ||||
| 		instance.enableHcaptcha && !hCaptchaResponse.value || | ||||
| 		instance.enableMcaptcha && !mCaptchaResponse.value || | ||||
| 		instance.enableRecaptcha && !reCaptchaResponse.value || | ||||
| 		instance.enableTurnstile && !turnstileResponse.value); | ||||
| }); | ||||
| import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; | ||||
| import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'login', v: any): void; | ||||
| 	(ev: 'login', v: Misskey.entities.SigninResponse): void; | ||||
| }>(); | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	withAvatar?: boolean; | ||||
| 	autoSet?: boolean; | ||||
| 	message?: string, | ||||
| 	openOnRemote?: OpenOnRemoteOptions, | ||||
| }>(), { | ||||
| 	withAvatar: true, | ||||
| 	autoSet: false, | ||||
| 	message: '', | ||||
| 	openOnRemote: undefined, | ||||
| }); | ||||
|  | ||||
| function onUsernameChange(): void { | ||||
| 	misskeyApi('users/show', { | ||||
| 		username: username.value, | ||||
| 	}).then(userResponse => { | ||||
| 		user.value = userResponse; | ||||
| 		usePasswordLessLogin.value = userResponse.usePasswordLessLogin; | ||||
| 	}, () => { | ||||
| 		user.value = null; | ||||
| 		usePasswordLessLogin.value = true; | ||||
| 	}); | ||||
| } | ||||
| const page = ref<'input' | 'password' | 'totp' | 'passkey'>('input'); | ||||
| const waiting = ref(false); | ||||
|  | ||||
| function onLogin(res: any): Promise<void> | void { | ||||
| 	if (props.autoSet) { | ||||
| 		return login(res.i); | ||||
| 	} | ||||
| } | ||||
| const passwordPageEl = useTemplateRef('passwordPageEl'); | ||||
| const needCaptcha = ref(false); | ||||
|  | ||||
| async function query2FaKey(): Promise<void> { | ||||
| 	if (credentialRequest == null) return; | ||||
| 	queryingKey.value = true; | ||||
| 	await webAuthnRequest(credentialRequest) | ||||
| 		.catch(() => { | ||||
| 			queryingKey.value = false; | ||||
| 			return Promise.reject(null); | ||||
| 		}).then(credential => { | ||||
| 			credentialRequest = null; | ||||
| 			queryingKey.value = false; | ||||
| 			signing.value = true; | ||||
| 			return misskeyApi('signin', { | ||||
| 				username: username.value, | ||||
| 				password: password.value, | ||||
| 				credential: credential.toJSON(), | ||||
| 			}); | ||||
| 		}).then(res => { | ||||
| 			emit('login', res); | ||||
| 			return onLogin(res); | ||||
| 		}).catch(err => { | ||||
| 			if (err === null) return; | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				text: i18n.ts.signinFailed, | ||||
| 			}); | ||||
| 			signing.value = false; | ||||
| 		}); | ||||
| } | ||||
| const userInfo = ref<null | Misskey.entities.UserDetailed>(null); | ||||
| const password = ref(''); | ||||
|  | ||||
| //#region Passkey Passwordless | ||||
| const credentialRequest = shallowRef<CredentialRequestOptions | null>(null); | ||||
| const passkeyContext = ref(''); | ||||
| const doingPasskeyFromInputPage = ref(false); | ||||
|  | ||||
| function onPasskeyLogin(): void { | ||||
| 	signing.value = true; | ||||
| 	if (webAuthnSupported()) { | ||||
| 		doingPasskeyFromInputPage.value = true; | ||||
| 		waiting.value = true; | ||||
| 		misskeyApi('signin-with-passkey', {}) | ||||
| 			.then(res => { | ||||
| 				totpLogin.value = false; | ||||
| 				signing.value = false; | ||||
| 				queryingKey.value = true; | ||||
| 				passkey_context.value = res.context ?? ''; | ||||
| 				credentialRequest = parseRequestOptionsFromJSON({ | ||||
| 			.then((res) => { | ||||
| 				passkeyContext.value = res.context ?? ''; | ||||
| 				credentialRequest.value = parseRequestOptionsFromJSON({ | ||||
| 					publicKey: res.option, | ||||
| 				}); | ||||
|  | ||||
| 				page.value = 'passkey'; | ||||
| 				waiting.value = false; | ||||
| 			}) | ||||
| 			.then(() => queryPasskey()) | ||||
| 			.catch(loginFailed); | ||||
| 			.catch(onLoginFailed); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function queryPasskey(): Promise<void> { | ||||
| 	if (credentialRequest == null) return; | ||||
| 	queryingKey.value = true; | ||||
| 	console.log('Waiting passkey auth...'); | ||||
| 	await webAuthnRequest(credentialRequest) | ||||
| 		.catch((err) => { | ||||
| 			console.warn('Passkey Auth fail!: ', err); | ||||
| 			queryingKey.value = false; | ||||
| 			return Promise.reject(null); | ||||
| 		}).then(credential => { | ||||
| 			credentialRequest = null; | ||||
| 			queryingKey.value = false; | ||||
| 			signing.value = true; | ||||
| 			return misskeyApi('signin-with-passkey', { | ||||
| 				credential: credential.toJSON(), | ||||
| 				context: passkey_context.value, | ||||
| 			}); | ||||
| 		}).then(res => { | ||||
| function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void { | ||||
| 	waiting.value = true; | ||||
|  | ||||
| 	if (doingPasskeyFromInputPage.value) { | ||||
| 		misskeyApi('signin-with-passkey', { | ||||
| 			credential: credential.toJSON(), | ||||
| 			context: passkeyContext.value, | ||||
| 		}).then((res) => { | ||||
| 			if (res.signinResponse == null) { | ||||
| 				onLoginFailed(); | ||||
| 				return; | ||||
| 			} | ||||
| 			emit('login', res.signinResponse); | ||||
| 			return onLogin(res.signinResponse); | ||||
| 		}).catch(onLoginFailed); | ||||
| 	} else if (userInfo.value != null) { | ||||
| 		tryLogin({ | ||||
| 			username: userInfo.value.username, | ||||
| 			password: password.value, | ||||
| 			credential: credential.toJSON(), | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function onSubmit(): void { | ||||
| 	signing.value = true; | ||||
| 	if (!totpLogin.value && user.value && user.value.twoFactorEnabled) { | ||||
| 		if (webAuthnSupported() && user.value.securityKeys) { | ||||
| 			misskeyApi('signin', { | ||||
| 				username: username.value, | ||||
| 				password: password.value, | ||||
| 			}).then(res => { | ||||
| 				totpLogin.value = true; | ||||
| 				signing.value = false; | ||||
| 				credentialRequest = parseRequestOptionsFromJSON({ | ||||
| 					publicKey: res, | ||||
| 				}); | ||||
| 			}) | ||||
| 				.then(() => query2FaKey()) | ||||
| 				.catch(loginFailed); | ||||
| 		} else { | ||||
| 			totpLogin.value = true; | ||||
| 			signing.value = false; | ||||
| function onUseTotp(): void { | ||||
| 	page.value = 'totp'; | ||||
| } | ||||
| //#endregion | ||||
|  | ||||
| async function onUsernameSubmitted(username: string) { | ||||
| 	waiting.value = true; | ||||
|  | ||||
| 	userInfo.value = await misskeyApi('users/show', { | ||||
| 		username, | ||||
| 	}).catch(() => null); | ||||
|  | ||||
| 	await tryLogin({ | ||||
| 		username, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function onPasswordSubmitted(pw: PwResponse) { | ||||
| 	waiting.value = true; | ||||
| 	password.value = pw.password; | ||||
|  | ||||
| 	if (userInfo.value == null) { | ||||
| 		await os.alert({ | ||||
| 			type: 'error', | ||||
| 			title: i18n.ts.noSuchUser, | ||||
| 			text: i18n.ts.signinFailed, | ||||
| 		}); | ||||
| 		waiting.value = false; | ||||
| 		return; | ||||
| 	} else { | ||||
| 		await tryLogin({ | ||||
| 			username: userInfo.value.username, | ||||
| 			password: pw.password, | ||||
| 			'hcaptcha-response': pw.captcha.hCaptchaResponse, | ||||
| 			'm-captcha-response': pw.captcha.mCaptchaResponse, | ||||
| 			'g-recaptcha-response': pw.captcha.reCaptchaResponse, | ||||
| 			'turnstile-response': pw.captcha.turnstileResponse, | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function onTotpSubmitted(token: string) { | ||||
| 	waiting.value = true; | ||||
|  | ||||
| 	if (userInfo.value == null) { | ||||
| 		await os.alert({ | ||||
| 			type: 'error', | ||||
| 			title: i18n.ts.noSuchUser, | ||||
| 			text: i18n.ts.signinFailed, | ||||
| 		}); | ||||
| 		waiting.value = false; | ||||
| 		return; | ||||
| 	} else { | ||||
| 		await tryLogin({ | ||||
| 			username: userInfo.value.username, | ||||
| 			password: password.value, | ||||
| 			token, | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function tryLogin(req: Partial<Misskey.entities.SigninRequest>): Promise<Misskey.entities.SigninResponse> { | ||||
| 	const _req = { | ||||
| 		username: req.username ?? userInfo.value?.username, | ||||
| 		...req, | ||||
| 	}; | ||||
|  | ||||
| 	function assertIsSigninRequest(x: Partial<Misskey.entities.SigninRequest>): x is Misskey.entities.SigninRequest { | ||||
| 		return x.username != null; | ||||
| 	} | ||||
|  | ||||
| 	if (!assertIsSigninRequest(_req)) { | ||||
| 		throw new Error('Invalid request'); | ||||
| 	} | ||||
|  | ||||
| 	return await misskeyApi('signin', _req).then(async (res) => { | ||||
| 		emit('login', res); | ||||
| 		await onLoginSucceeded(res); | ||||
| 		return res; | ||||
| 	}).catch((err) => { | ||||
| 		onLoginFailed(err); | ||||
| 		return Promise.reject(err); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function onLoginSucceeded(res: Misskey.entities.SigninResponse) { | ||||
| 	if (props.autoSet) { | ||||
| 		await login(res.i); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function onLoginFailed(err?: any): void { | ||||
| 	const id = err?.id ?? null; | ||||
|  | ||||
| 	if (typeof err === 'object' && 'next' in err) { | ||||
| 		switch (err.next) { | ||||
| 			case 'captcha': { | ||||
| 				page.value = 'password'; | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'password': { | ||||
| 				page.value = 'password'; | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'totp': { | ||||
| 				page.value = 'totp'; | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'passkey': { | ||||
| 				if (webAuthnSupported() && 'authRequest' in err) { | ||||
| 					credentialRequest.value = parseRequestOptionsFromJSON({ | ||||
| 						publicKey: err.authRequest, | ||||
| 					}); | ||||
| 					page.value = 'passkey'; | ||||
| 				} else { | ||||
| 					page.value = 'totp'; | ||||
| 				} | ||||
| 				break; | ||||
| 			} | ||||
| 		} | ||||
| 	} else { | ||||
| 		misskeyApi('signin', { | ||||
| 			username: username.value, | ||||
| 			password: password.value, | ||||
| 			'hcaptcha-response': hCaptchaResponse.value, | ||||
| 			'm-captcha-response': mCaptchaResponse.value, | ||||
| 			'g-recaptcha-response': reCaptchaResponse.value, | ||||
| 			'turnstile-response': turnstileResponse.value, | ||||
| 			token: user.value?.twoFactorEnabled ? token.value : undefined, | ||||
| 		}).then(res => { | ||||
| 			emit('login', res); | ||||
| 			onLogin(res); | ||||
| 		}).catch(loginFailed); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function loginFailed(err: any): void { | ||||
| 	hcaptcha.value?.reset?.(); | ||||
| 	mcaptcha.value?.reset?.(); | ||||
| 	recaptcha.value?.reset?.(); | ||||
| 	turnstile.value?.reset?.(); | ||||
|  | ||||
| 	switch (err.id) { | ||||
| 		case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| 				text: i18n.ts.noSuchUser, | ||||
| 			}); | ||||
| 			break; | ||||
| 		} | ||||
| 		case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| 				text: i18n.ts.incorrectPassword, | ||||
| 			}); | ||||
| 			break; | ||||
| 		} | ||||
| 		case 'e03a5f46-d309-4865-9b69-56282d94e1eb': { | ||||
| 			showSuspendedDialog(); | ||||
| 			break; | ||||
| 		} | ||||
| 		case '22d05606-fbcf-421a-a2db-b32610dcfd1b': { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| 				text: i18n.ts.rateLimitExceeded, | ||||
| 			}); | ||||
| 			break; | ||||
| 		} | ||||
| 		case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| 				text: i18n.ts.unknownWebAuthnKey, | ||||
| 			}); | ||||
| 			break; | ||||
| 		} | ||||
| 		case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| 				text: i18n.ts.passkeyVerificationFailed, | ||||
| 			}); | ||||
| 			break; | ||||
| 		} | ||||
| 		case '2d84773e-f7b7-4d0b-8f72-bb69b584c912': { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| 				text: i18n.ts.passkeyVerificationSucceededButPasswordlessLoginDisabled, | ||||
| 			}); | ||||
| 			break; | ||||
| 		} | ||||
| 		default: { | ||||
| 			console.error(err); | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				title: i18n.ts.loginFailed, | ||||
| 				text: JSON.stringify(err), | ||||
| 			}); | ||||
| 		switch (id) { | ||||
| 			case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.noSuchUser, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.incorrectPassword, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'e03a5f46-d309-4865-9b69-56282d94e1eb': { | ||||
| 				showSuspendedDialog(); | ||||
| 				break; | ||||
| 			} | ||||
| 			case '22d05606-fbcf-421a-a2db-b32610dcfd1b': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.rateLimitExceeded, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.incorrectTotp, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.unknownWebAuthnKey, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			case '93b86c4b-72f9-40eb-9815-798928603d1e': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.passkeyVerificationFailed, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.passkeyVerificationFailed, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			case '2d84773e-f7b7-4d0b-8f72-bb69b584c912': { | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: i18n.ts.passkeyVerificationSucceededButPasswordlessLoginDisabled, | ||||
| 				}); | ||||
| 				break; | ||||
| 			} | ||||
| 			default: { | ||||
| 				console.error(err); | ||||
| 				os.alert({ | ||||
| 					type: 'error', | ||||
| 					title: i18n.ts.loginFailed, | ||||
| 					text: JSON.stringify(err), | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	totpLogin.value = false; | ||||
| 	signing.value = false; | ||||
| } | ||||
|  | ||||
| function resetPassword(): void { | ||||
| 	const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { | ||||
| 		closed: () => dispose(), | ||||
| 	if (doingPasskeyFromInputPage.value === true) { | ||||
| 		doingPasskeyFromInputPage.value = false; | ||||
| 		page.value = 'input'; | ||||
| 		password.value = ''; | ||||
| 	} | ||||
| 	passwordPageEl.value?.resetCaptcha(); | ||||
| 	nextTick(() => { | ||||
| 		waiting.value = false; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { | ||||
| 	switch (options.type) { | ||||
| 		case 'web': | ||||
| 		case 'lookup': { | ||||
| 			let _path: string; | ||||
|  | ||||
| 			if (options.type === 'lookup') { | ||||
| 				// TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼ | ||||
| 				// _path = `/lookup?uri=${encodeURIComponent(_path)}`; | ||||
| 				_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`; | ||||
| 			} else { | ||||
| 				_path = options.path; | ||||
| 			} | ||||
|  | ||||
| 			if (targetHost) { | ||||
| 				window.open(`https://${targetHost}${_path}`, '_blank', 'noopener'); | ||||
| 			} else { | ||||
| 				window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener'); | ||||
| 			} | ||||
| 			break; | ||||
| 		} | ||||
| 		case 'share': { | ||||
| 			const params = query(options.params); | ||||
| 			if (targetHost) { | ||||
| 				window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener'); | ||||
| 			} else { | ||||
| 				window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener'); | ||||
| 			} | ||||
| 			break; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> { | ||||
| 	const { canceled, result: hostTemp } = await os.inputText({ | ||||
| 		title: i18n.ts.inputHostName, | ||||
| 		placeholder: 'misskey.example.com', | ||||
| 	}); | ||||
|  | ||||
| 	if (canceled) return; | ||||
|  | ||||
| 	let targetHost: string | null = hostTemp; | ||||
|  | ||||
| 	// ドメイン部分だけを取り出す | ||||
| 	targetHost = extractDomain(targetHost); | ||||
| 	if (targetHost == null) { | ||||
| 		os.alert({ | ||||
| 			type: 'error', | ||||
| 			title: i18n.ts.invalidValue, | ||||
| 			text: i18n.ts.tryAgain, | ||||
| 		}); | ||||
| 		return; | ||||
| 	} | ||||
| 	openRemote(options, targetHost); | ||||
| } | ||||
| onBeforeUnmount(() => { | ||||
| 	password.value = ''; | ||||
| 	userInfo.value = null; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .avatar { | ||||
| 	margin: 0 auto 0 auto; | ||||
| 	width: 64px; | ||||
| 	height: 64px; | ||||
| 	background: #ddd; | ||||
| 	background-position: center; | ||||
| 	background-size: cover; | ||||
| 	border-radius: 100%; | ||||
| .transition_enterActive, | ||||
| .transition_leaveActive { | ||||
| 	transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); | ||||
| } | ||||
| .transition_enterFrom { | ||||
| 	opacity: 0; | ||||
| 	transform: translateX(50px); | ||||
| } | ||||
| .transition_leaveTo { | ||||
| 	opacity: 0; | ||||
| 	transform: translateX(-50px); | ||||
| } | ||||
|  | ||||
| .instanceManualSelectButton { | ||||
| 	display: block; | ||||
| 	text-align: center; | ||||
| 	opacity: .7; | ||||
| 	font-size: .8em; | ||||
| .signinRoot { | ||||
| 	overflow-x: hidden; | ||||
| 	overflow-x: clip; | ||||
|  | ||||
| 	&:hover { | ||||
| 		text-decoration: underline; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .orHr { | ||||
| 	position: relative; | ||||
| 	margin: .4em auto; | ||||
| 	width: 100%; | ||||
| 	height: 1px; | ||||
| 	background: var(--divider); | ||||
| } | ||||
|  | ||||
| .orMsg { | ||||
| .waitingRoot { | ||||
| 	position: absolute; | ||||
| 	top: -.6em; | ||||
| 	display: inline-block; | ||||
| 	padding: 0 1em; | ||||
| 	background: var(--panel); | ||||
| 	font-size: 0.8em; | ||||
| 	color: var(--fgOnPanel); | ||||
| 	margin: 0; | ||||
| 	left: 50%; | ||||
| 	transform: translateX(-50%); | ||||
| 	top: 0; | ||||
| 	left: 0; | ||||
| 	width: 100%; | ||||
| 	height: 100%; | ||||
| 	background-color: color-mix(in srgb, var(--panel), transparent 50%); | ||||
| 	display: flex; | ||||
| 	justify-content: center; | ||||
| 	align-items: center; | ||||
| 	z-index: 1; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -4,26 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <MkModalWindow | ||||
| 	ref="dialog" | ||||
| 	:width="400" | ||||
| 	:height="450" | ||||
| 	@close="onClose" | ||||
| <MkModal | ||||
| 	ref="modal" | ||||
| 	:preferType="'dialog'" | ||||
| 	@click="onClose" | ||||
| 	@closed="emit('closed')" | ||||
| > | ||||
| 	<template #header>{{ i18n.ts.login }}</template> | ||||
|  | ||||
| 	<MkSpacer :marginMin="20" :marginMax="28"> | ||||
| 		<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/> | ||||
| 	</MkSpacer> | ||||
| </MkModalWindow> | ||||
| 	<div :class="$style.root"> | ||||
| 		<div :class="$style.header"> | ||||
| 			<div :class="$style.headerText"><i class="ti ti-login-2"></i> {{ i18n.ts.login }}</div> | ||||
| 			<button :class="$style.closeButton" class="_button" @click="onClose"><i class="ti ti-x"></i></button> | ||||
| 		</div> | ||||
| 		<div :class="$style.content"> | ||||
| 			<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </MkModal> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { shallowRef } from 'vue'; | ||||
| import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; | ||||
| import MkSignin from '@/components/MkSignin.vue'; | ||||
| import MkModalWindow from '@/components/MkModalWindow.vue'; | ||||
| import MkModal from '@/components/MkModal.vue'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | ||||
| withDefaults(defineProps<{ | ||||
| @@ -42,15 +45,62 @@ const emit = defineEmits<{ | ||||
| 	(ev: 'cancelled'): void; | ||||
| }>(); | ||||
|  | ||||
| const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); | ||||
| const modal = shallowRef<InstanceType<typeof MkModal>>(); | ||||
|  | ||||
| function onClose() { | ||||
| 	emit('cancelled'); | ||||
| 	if (dialog.value) dialog.value.close(); | ||||
| 	if (modal.value) modal.value.close(); | ||||
| } | ||||
|  | ||||
| function onLogin(res) { | ||||
| 	emit('done', res); | ||||
| 	if (dialog.value) dialog.value.close(); | ||||
| 	if (modal.value) modal.value.close(); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	overflow: auto; | ||||
| 	margin: auto; | ||||
| 	position: relative; | ||||
| 	width: 100%; | ||||
| 	max-width: 400px; | ||||
| 	height: 100%; | ||||
| 	max-height: 450px; | ||||
| 	box-sizing: border-box; | ||||
| 	background: var(--panel); | ||||
| 	border-radius: var(--radius); | ||||
| } | ||||
|  | ||||
| .header { | ||||
| 	position: sticky; | ||||
| 	top: 0; | ||||
| 	left: 0; | ||||
| 	width: 100%; | ||||
| 	height: 50px; | ||||
| 	box-sizing: border-box; | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	font-weight: bold; | ||||
| 	backdrop-filter: var(--blur, blur(15px)); | ||||
| 	background: var(--acrylicBg); | ||||
| 	z-index: 1; | ||||
| } | ||||
|  | ||||
| .headerText { | ||||
| 	padding: 0 20px; | ||||
| 	box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| .closeButton { | ||||
| 	margin-left: auto; | ||||
| 	padding: 16px; | ||||
| 	font-size: 16px; | ||||
| 	line-height: 16px; | ||||
| } | ||||
|  | ||||
| .content { | ||||
| 	padding: 32px; | ||||
| 	box-sizing: border-box; | ||||
| } | ||||
| </style> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 かっこかり
					かっこかり