diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2024-10-04 15:23:33 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-04 15:23:33 +0900 |
| commit | 975c2e7bc567618c3f8b0082afcba6530d679dae (patch) | |
| tree | 2268801e42af4285f851b08077bbe9d569755156 /packages/frontend/src/components | |
| parent | Update generate.tsx (diff) | |
| download | misskey-975c2e7bc567618c3f8b0082afcba6530d679dae.tar.gz misskey-975c2e7bc567618c3f8b0082afcba6530d679dae.tar.bz2 misskey-975c2e7bc567618c3f8b0082afcba6530d679dae.zip | |
enhance(frontend): サインイン画面の改善 (#14658)
* wip
* Update MkSignin.vue
* Update MkSignin.vue
* wip
* Update CHANGELOG.md
* enhance(frontend): サインイン画面の改善
* Update Changelog
* 14655の変更取り込み
* spdx
* fix
* fix
* fix
* :art:
* :art:
* :art:
* :art:
* Captchaがリセットされない問題を修正
* 次の処理をsignin apiから読み取るように
* Add Comments
* fix
* fix test
* attempt to fix test
* fix test
* fix test
* fix test
* fix
* fix test
* fix: 一部のエラーがちゃんと出るように
* Update Changelog
* :art:
* :art:
* remove border
---------
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src/components')
| -rw-r--r-- | packages/frontend/src/components/MkSignin.input.vue | 206 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSignin.passkey.vue | 92 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSignin.password.vue | 181 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSignin.totp.vue | 74 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSignin.vue | 688 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSigninDialog.vue | 80 |
6 files changed, 944 insertions, 377 deletions
diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue new file mode 100644 index 0000000000..6336b78c80 --- /dev/null +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -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> diff --git a/packages/frontend/src/components/MkSignin.passkey.vue b/packages/frontend/src/components/MkSignin.passkey.vue new file mode 100644 index 0000000000..0d68955fab --- /dev/null +++ b/packages/frontend/src/components/MkSignin.passkey.vue @@ -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> diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue new file mode 100644 index 0000000000..2d79e2aeb1 --- /dev/null +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -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> diff --git a/packages/frontend/src/components/MkSignin.totp.vue b/packages/frontend/src/components/MkSignin.totp.vue new file mode 100644 index 0000000000..880c08315e --- /dev/null +++ b/packages/frontend/src/components/MkSignin.totp.vue @@ -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> diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index abbff8e1f2..81a98cae0e 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -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 { - misskeyApi('signin', { - username: username.value, + await tryLogin({ + username: userInfo.value.username, 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); + token, + }); } } -function loginFailed(err: any): void { - hcaptcha.value?.reset?.(); - mcaptcha.value?.reset?.(); - recaptcha.value?.reset?.(); - turnstile.value?.reset?.(); +async function tryLogin(req: Partial<Misskey.entities.SigninRequest>): Promise<Misskey.entities.SigninResponse> { + const _req = { + username: req.username ?? userInfo.value?.username, + ...req, + }; - 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), - }); - } + function assertIsSigninRequest(x: Partial<Misskey.entities.SigninRequest>): x is Misskey.entities.SigninRequest { + return x.username != null; } - totpLogin.value = false; - signing.value = false; -} + if (!assertIsSigninRequest(_req)) { + throw new Error('Invalid request'); + } -function resetPassword(): void { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { - closed: () => dispose(), + return await misskeyApi('signin', _req).then(async (res) => { + emit('login', res); + await onLoginSucceeded(res); + return res; + }).catch((err) => { + onLoginFailed(err); + return Promise.reject(err); }); } -function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { - switch (options.type) { - case 'web': - case 'lookup': { - let _path: string; +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 (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 (typeof err === 'object' && 'next' in err) { + switch (err.next) { + case 'captcha': { + page.value = 'password'; + break; } - - if (targetHost) { - window.open(`https://${targetHost}${_path}`, '_blank', 'noopener'); - } else { - window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener'); + 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; } - 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'); + } 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), + }); } - 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; + if (doingPasskeyFromInputPage.value === true) { + doingPasskeyFromInputPage.value = false; + page.value = 'input'; + password.value = ''; } - openRemote(options, targetHost); + passwordPageEl.value?.resetCaptcha(); + nextTick(() => { + waiting.value = false; + }); } + +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); } - -.instanceManualSelectButton { - display: block; - text-align: center; - opacity: .7; - font-size: .8em; - - &:hover { - text-decoration: underline; - } +.transition_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_leaveTo { + opacity: 0; + transform: translateX(-50px); } -.orHr { +.signinRoot { + overflow-x: hidden; + overflow-x: clip; + 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> diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index d48780e9de..8351d7d5e0 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -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> |