<!-- SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <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> </div> </template> <script setup lang="ts"> import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-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 * as os from '@/os.js'; 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'; import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; const emit = defineEmits<{ (ev: 'login', v: Misskey.entities.SigninResponse): void; }>(); const props = withDefaults(defineProps<{ autoSet?: boolean; message?: string, openOnRemote?: OpenOnRemoteOptions, }>(), { autoSet: false, message: '', openOnRemote: undefined, }); const page = ref<'input' | 'password' | 'totp' | 'passkey'>('input'); const waiting = ref(false); const passwordPageEl = useTemplateRef('passwordPageEl'); const needCaptcha = ref(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 { if (webAuthnSupported()) { doingPasskeyFromInputPage.value = true; waiting.value = true; misskeyApi('signin-with-passkey', {}) .then((res) => { passkeyContext.value = res.context ?? ''; credentialRequest.value = parseRequestOptionsFromJSON({ publicKey: res.option, }); page.value = 'passkey'; waiting.value = false; }) .catch(onSigninApiError); } } 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) { onSigninApiError(); return; } emit('login', res.signinResponse); }).catch(onSigninApiError); } else if (userInfo.value != null) { tryLogin({ username: userInfo.value.username, password: password.value, credential: credential.toJSON(), }); } } 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) => { onSigninApiError(err); return Promise.reject(err); }); } async function onLoginSucceeded(res: Misskey.entities.SigninResponse) { if (props.autoSet) { await login(res.i); } } function onSigninApiError(err?: any): void { const id = err?.id ?? null; if (typeof err === 'object' && 'next' in err) { switch (err.next) { case 'captcha': { needCaptcha.value = true; page.value = 'password'; break; } case 'password': { needCaptcha.value = false; 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 { 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), }); } } } if (doingPasskeyFromInputPage.value === true) { doingPasskeyFromInputPage.value = false; page.value = 'input'; password.value = ''; } passwordPageEl.value?.resetCaptcha(); nextTick(() => { waiting.value = false; }); } onBeforeUnmount(() => { password.value = ''; needCaptcha.value = false; userInfo.value = null; }); </script> <style lang="scss" module> .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); } .signinRoot { overflow-x: hidden; overflow-x: clip; position: relative; } .waitingRoot { position: absolute; 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>