enhance(frontend): サインイン画面の改善
This commit is contained in:
parent
d5a3fb1916
commit
80d068c732
|
@ -122,8 +122,13 @@ describe('After user signup', () => {
|
||||||
cy.intercept('POST', '/api/signin').as('signin');
|
cy.intercept('POST', '/api/signin').as('signin');
|
||||||
|
|
||||||
cy.get('[data-cy-signin]').click();
|
cy.get('[data-cy-signin]').click();
|
||||||
cy.get('[data-cy-signin-username] input').type('alice');
|
|
||||||
// Enterキーでサインインできるかの確認も兼ねる
|
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||||
|
// Enterキーで続行できるかの確認も兼ねる
|
||||||
|
cy.get('[data-cy-signin-username] input').type('alice{enter}');
|
||||||
|
|
||||||
|
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
|
||||||
|
// Enterキーで続行できるかの確認も兼ねる
|
||||||
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
|
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
|
||||||
|
|
||||||
cy.wait('@signin');
|
cy.wait('@signin');
|
||||||
|
@ -137,8 +142,9 @@ describe('After user signup', () => {
|
||||||
|
|
||||||
cy.visitHome();
|
cy.visitHome();
|
||||||
|
|
||||||
cy.get('[data-cy-signin]').click();
|
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||||
cy.get('[data-cy-signin-username] input').type('alice');
|
cy.get('[data-cy-signin-username] input').type('alice{enter}');
|
||||||
|
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
|
||||||
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
|
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
|
||||||
|
|
||||||
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
|
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
|
||||||
|
|
|
@ -57,7 +57,9 @@ Cypress.Commands.add('login', (username, password) => {
|
||||||
cy.intercept('POST', '/api/signin').as('signin');
|
cy.intercept('POST', '/api/signin').as('signin');
|
||||||
|
|
||||||
cy.get('[data-cy-signin]').click();
|
cy.get('[data-cy-signin]').click();
|
||||||
cy.get('[data-cy-signin-username] input').type(username);
|
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||||
|
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
|
||||||
|
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
|
||||||
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
|
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
|
||||||
|
|
||||||
cy.wait('@signin').as('signedIn');
|
cy.wait('@signin').as('signedIn');
|
||||||
|
|
|
@ -3700,6 +3700,10 @@ export interface Locale extends ILocale {
|
||||||
* パスワードが間違っています。
|
* パスワードが間違っています。
|
||||||
*/
|
*/
|
||||||
"incorrectPassword": string;
|
"incorrectPassword": string;
|
||||||
|
/**
|
||||||
|
* ワンタイムパスワードが間違っているか、期限切れになっています。
|
||||||
|
*/
|
||||||
|
"incorrectTotp": string;
|
||||||
/**
|
/**
|
||||||
* 「{choice}」に投票しますか?
|
* 「{choice}」に投票しますか?
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -921,6 +921,7 @@ followersVisibility: "フォロワーの公開範囲"
|
||||||
continueThread: "さらにスレッドを見る"
|
continueThread: "さらにスレッドを見る"
|
||||||
deleteAccountConfirm: "アカウントが削除されます。よろしいですか?"
|
deleteAccountConfirm: "アカウントが削除されます。よろしいですか?"
|
||||||
incorrectPassword: "パスワードが間違っています。"
|
incorrectPassword: "パスワードが間違っています。"
|
||||||
|
incorrectTotp: "ワンタイムパスワードが間違っているか、期限切れになっています。"
|
||||||
voteConfirm: "「{choice}」に投票しますか?"
|
voteConfirm: "「{choice}」に投票しますか?"
|
||||||
hide: "隠す"
|
hide: "隠す"
|
||||||
useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示"
|
useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示"
|
||||||
|
|
|
@ -0,0 +1,197 @@
|
||||||
|
<template>
|
||||||
|
<div :class="$style.wrapper" data-cy-signin-page-input>
|
||||||
|
<div class="_gaps" :class="$style.root">
|
||||||
|
<div :class="$style.avatar" :style="{ marginBottom: message ? '1.5em' : undefined }">
|
||||||
|
<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;" rounded primary @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 { i18n } from '@/i18n.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
|
||||||
|
import { query, extractDomain } from '@@/js/url.js';
|
||||||
|
import { host as configHost } from '@@/js/config.js';
|
||||||
|
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
|
||||||
|
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||||
|
|
||||||
|
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>
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 360px;
|
||||||
|
|
||||||
|
> .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>
|
|
@ -0,0 +1,89 @@
|
||||||
|
<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 * as Misskey from 'misskey-js';
|
||||||
|
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<{
|
||||||
|
user: Misskey.entities.UserDetailed;
|
||||||
|
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: 360px;
|
||||||
|
|
||||||
|
> .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>
|
|
@ -0,0 +1,150 @@
|
||||||
|
<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="!user.twoFactorEnabled">
|
||||||
|
<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" 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, 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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'passwordSubmitted', v: PwResponse): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const password = ref('');
|
||||||
|
|
||||||
|
const hCaptchaResponse = ref<string | null>(null);
|
||||||
|
const mCaptchaResponse = ref<string | null>(null);
|
||||||
|
const reCaptchaResponse = ref<string | null>(null);
|
||||||
|
const turnstileResponse = ref<string | null>(null);
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 360px;
|
||||||
|
|
||||||
|
> .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>
|
|
@ -0,0 +1,69 @@
|
||||||
|
<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: 360px;
|
||||||
|
|
||||||
|
> .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,252 +4,266 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
|
<div :class="$style.signinRoot">
|
||||||
<div class="_gaps_m">
|
<Transition
|
||||||
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
|
mode="out-in"
|
||||||
<MkInfo v-if="message">
|
:enterActiveClass="$style.transition_enterActive"
|
||||||
{{ message }}
|
:leaveActiveClass="$style.transition_leaveActive"
|
||||||
</MkInfo>
|
:enterFromClass="$style.transition_enterFrom"
|
||||||
<div v-if="openOnRemote" class="_gaps_m">
|
:leaveToClass="$style.transition_leaveTo"
|
||||||
<div class="_gaps_s">
|
|
||||||
<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)">
|
:inert="waiting"
|
||||||
{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i>
|
>
|
||||||
</MkButton>
|
<!-- 1. 外部サーバーへの転送・username入力・パスキー -->
|
||||||
<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)">
|
<XInput
|
||||||
{{ i18n.ts.specifyServerHost }}
|
v-if="page === 'input'"
|
||||||
</button>
|
key="input"
|
||||||
</div>
|
:message="message"
|
||||||
<div :class="$style.orHr">
|
:openOnRemote="openOnRemote"
|
||||||
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
|
||||||
|
@usernameSubmitted="onUsernameSubmitted"
|
||||||
|
@passkeyClick="onPasskeyLogin"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 2. パスワード入力 -->
|
||||||
|
<XPassword
|
||||||
|
v-else-if="page === 'password'"
|
||||||
|
key="password"
|
||||||
|
|
||||||
|
:user="userInfo!"
|
||||||
|
|
||||||
|
@passwordSubmitted="onPasswordSubmitted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 3. ワンタイムパスワード -->
|
||||||
|
<XTotp
|
||||||
|
v-else-if="page === 'totp'"
|
||||||
|
key="totp"
|
||||||
|
|
||||||
|
@totpSubmitted="onTotpSubmitted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 4. パスキー -->
|
||||||
|
<XPasskey
|
||||||
|
v-else-if="page === 'passkey'"
|
||||||
|
key="passkey"
|
||||||
|
|
||||||
|
:user="userInfo!"
|
||||||
|
:credentialRequest="credentialRequest!"
|
||||||
|
:isPerformingPasswordlessLogin="doingPasskeyFromInputPage"
|
||||||
|
|
||||||
|
@done="onPasskeyDone"
|
||||||
|
@useTotp="onUseTotp"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<div v-if="waiting" :class="$style.waitingRoot">
|
||||||
|
<MkLoading/>
|
||||||
</div>
|
</div>
|
||||||
</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="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>
|
|
||||||
</form>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import { defineAsyncComponent, ref } from 'vue';
|
import { ref, shallowRef } from 'vue';
|
||||||
import { toUnicode } from 'punycode/';
|
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
|
import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
|
||||||
import { SigninWithPasskeyResponse } from 'misskey-js/entities.js';
|
|
||||||
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 { misskeyApi } from '@/scripts/misskey-api.js';
|
|
||||||
import { login } from '@/account.js';
|
|
||||||
import { i18n } from '@/i18n.js';
|
|
||||||
import { instance } from '@/instance.js';
|
|
||||||
|
|
||||||
const signing = ref(false);
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
const user = ref<Misskey.entities.UserDetailed | null>(null);
|
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||||
const usePasswordLessLogin = ref<Misskey.entities.UserDetailed['usePasswordLessLogin']>(true);
|
import { login } from '@/account.js';
|
||||||
const username = ref('');
|
import { instance } from '@/instance.js';
|
||||||
const password = ref('');
|
import { i18n } from '@/i18n.js';
|
||||||
const token = ref('');
|
import * as os from '@/os.js';
|
||||||
const host = ref(toUnicode(configHost));
|
|
||||||
const totpLogin = ref(false);
|
import XInput from '@/components/MkSignin.input.vue';
|
||||||
const isBackupCode = ref(false);
|
import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue';
|
||||||
const queryingKey = ref(false);
|
import XTotp from '@/components/MkSignin.totp.vue';
|
||||||
let credentialRequest: CredentialRequestOptions | null = null;
|
import XPasskey from '@/components/MkSignin.passkey.vue';
|
||||||
const passkey_context = ref('');
|
|
||||||
const hCaptchaResponse = ref<string | null>(null);
|
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
|
||||||
const mCaptchaResponse = ref<string | null>(null);
|
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||||
const reCaptchaResponse = ref<string | null>(null);
|
|
||||||
const turnstileResponse = ref<string | null>(null);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'login', v: any): void;
|
(ev: 'login', v: Misskey.entities.SigninResponse): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
withAvatar?: boolean;
|
|
||||||
autoSet?: boolean;
|
autoSet?: boolean;
|
||||||
message?: string,
|
message?: string,
|
||||||
openOnRemote?: OpenOnRemoteOptions,
|
openOnRemote?: OpenOnRemoteOptions,
|
||||||
}>(), {
|
}>(), {
|
||||||
withAvatar: true,
|
|
||||||
autoSet: false,
|
autoSet: false,
|
||||||
message: '',
|
message: '',
|
||||||
openOnRemote: undefined,
|
openOnRemote: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
function onUsernameChange(): void {
|
const page = ref<'input' | 'password' | 'totp' | 'passkey'>('input');
|
||||||
misskeyApi('users/show', {
|
const waiting = ref(false);
|
||||||
username: username.value,
|
|
||||||
}).then(userResponse => {
|
|
||||||
user.value = userResponse;
|
|
||||||
usePasswordLessLogin.value = userResponse.usePasswordLessLogin;
|
|
||||||
}, () => {
|
|
||||||
user.value = null;
|
|
||||||
usePasswordLessLogin.value = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLogin(res: any): Promise<void> | void {
|
const userInfo = ref<null | Misskey.entities.UserDetailed>(null);
|
||||||
if (props.autoSet) {
|
const password = ref('');
|
||||||
return login(res.i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function query2FaKey(): Promise<void> {
|
//#region Passkey Passwordless
|
||||||
if (credentialRequest == null) return;
|
const credentialRequest = shallowRef<CredentialRequestOptions | null>(null);
|
||||||
queryingKey.value = true;
|
const passkeyContext = ref('');
|
||||||
await webAuthnRequest(credentialRequest)
|
const doingPasskeyFromInputPage = ref(false);
|
||||||
.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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPasskeyLogin(): void {
|
function onPasskeyLogin(): void {
|
||||||
signing.value = true;
|
|
||||||
if (webAuthnSupported()) {
|
if (webAuthnSupported()) {
|
||||||
|
doingPasskeyFromInputPage.value = true;
|
||||||
|
waiting.value = true;
|
||||||
misskeyApi('signin-with-passkey', {})
|
misskeyApi('signin-with-passkey', {})
|
||||||
.then((res: SigninWithPasskeyResponse) => {
|
.then((res) => {
|
||||||
totpLogin.value = false;
|
passkeyContext.value = res.context ?? '';
|
||||||
signing.value = false;
|
credentialRequest.value = parseRequestOptionsFromJSON({
|
||||||
queryingKey.value = true;
|
|
||||||
passkey_context.value = res.context ?? '';
|
|
||||||
credentialRequest = parseRequestOptionsFromJSON({
|
|
||||||
publicKey: res.option,
|
publicKey: res.option,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
page.value = 'passkey';
|
||||||
|
waiting.value = false;
|
||||||
})
|
})
|
||||||
.then(() => queryPasskey())
|
.catch(onLoginFailed);
|
||||||
.catch(loginFailed);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queryPasskey(): Promise<void> {
|
function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void {
|
||||||
if (credentialRequest == null) return;
|
waiting.value = true;
|
||||||
queryingKey.value = true;
|
|
||||||
console.log('Waiting passkey auth...');
|
if (doingPasskeyFromInputPage.value) {
|
||||||
await webAuthnRequest(credentialRequest)
|
misskeyApi('signin-with-passkey', {
|
||||||
.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(),
|
credential: credential.toJSON(),
|
||||||
context: passkey_context.value,
|
context: passkeyContext.value,
|
||||||
});
|
}).then((res) => {
|
||||||
}).then((res: SigninWithPasskeyResponse) => {
|
if (res.signinResponse == null) {
|
||||||
|
onLoginFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit('login', res.signinResponse);
|
emit('login', res.signinResponse);
|
||||||
return onLogin(res.signinResponse);
|
}).catch(onLoginFailed);
|
||||||
});
|
} else if (userInfo.value != null) {
|
||||||
|
misskeyApi('signin', {
|
||||||
|
username: userInfo.value.username,
|
||||||
|
password: password.value,
|
||||||
|
credential: credential.toJSON(),
|
||||||
|
}).then(async (res) => {
|
||||||
|
emit('login', res);
|
||||||
|
await onLoginSucceeded(res);
|
||||||
|
}).catch(onLoginFailed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(): void {
|
function onUseTotp(): void {
|
||||||
signing.value = true;
|
page.value = 'totp';
|
||||||
if (!totpLogin.value && user.value && user.value.twoFactorEnabled) {
|
}
|
||||||
if (webAuthnSupported() && user.value.securityKeys) {
|
//#endregion
|
||||||
misskeyApi('signin', {
|
|
||||||
username: username.value,
|
async function onUsernameSubmitted(username: string) {
|
||||||
password: password.value,
|
waiting.value = true;
|
||||||
}).then(res => {
|
|
||||||
totpLogin.value = true;
|
userInfo.value = await misskeyApi('users/show', {
|
||||||
signing.value = false;
|
username,
|
||||||
credentialRequest = parseRequestOptionsFromJSON({
|
});
|
||||||
|
|
||||||
|
if (userInfo.value == null) {
|
||||||
|
await os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.noSuchUser,
|
||||||
|
text: i18n.ts.signinFailed,
|
||||||
|
});
|
||||||
|
} else if (userInfo.value.usePasswordLessLogin) {
|
||||||
|
page.value = 'passkey';
|
||||||
|
} else {
|
||||||
|
page.value = 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPasswordSubmitted(pw: PwResponse) {
|
||||||
|
waiting.value = true;
|
||||||
|
|
||||||
|
if (userInfo.value == null) {
|
||||||
|
await os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.noSuchUser,
|
||||||
|
text: i18n.ts.signinFailed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
if (!userInfo.value.twoFactorEnabled) {
|
||||||
|
if (
|
||||||
|
(instance.enableHcaptcha || instance.enableMcaptcha || instance.enableRecaptcha || instance.enableTurnstile) &&
|
||||||
|
(pw.captcha.hCaptchaResponse == null && pw.captcha.mCaptchaResponse == null && pw.captcha.reCaptchaResponse == null && pw.captcha.turnstileResponse == null)
|
||||||
|
) {
|
||||||
|
// 2FAが無効で、CAPTCHAが有効で、かつCAPTCHAが未入力の場合
|
||||||
|
onLoginFailed();
|
||||||
|
waiting.value = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await misskeyApi('signin', {
|
||||||
|
username: userInfo.value.username,
|
||||||
|
password: pw.password,
|
||||||
|
'h-captcha-response': pw.captcha.hCaptchaResponse,
|
||||||
|
'm-captcha-response': pw.captcha.mCaptchaResponse,
|
||||||
|
'g-recaptcha-response': pw.captcha.reCaptchaResponse,
|
||||||
|
'turnstile-response': pw.captcha.turnstileResponse,
|
||||||
|
}).then(async (res) => {
|
||||||
|
emit('login', res);
|
||||||
|
await onLoginSucceeded(res);
|
||||||
|
}).catch(onLoginFailed);
|
||||||
|
}
|
||||||
|
} else if (userInfo.value.securityKeys) {
|
||||||
|
password.value = pw.password;
|
||||||
|
|
||||||
|
await misskeyApi('signin', {
|
||||||
|
username: userInfo.value.username,
|
||||||
|
password: pw.password,
|
||||||
|
}).then((res) => {
|
||||||
|
credentialRequest.value = parseRequestOptionsFromJSON({
|
||||||
publicKey: res,
|
publicKey: res,
|
||||||
});
|
});
|
||||||
})
|
page.value = 'passkey';
|
||||||
.then(() => query2FaKey())
|
waiting.value = false;
|
||||||
.catch(loginFailed);
|
}).catch(onLoginFailed);
|
||||||
} else {
|
} else {
|
||||||
totpLogin.value = true;
|
password.value = pw.password;
|
||||||
signing.value = false;
|
page.value = 'totp';
|
||||||
|
waiting.value = false;
|
||||||
}
|
}
|
||||||
} 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 {
|
async function onTotpSubmitted(token: string) {
|
||||||
switch (err.id) {
|
waiting.value = true;
|
||||||
|
|
||||||
|
if (userInfo.value == null) {
|
||||||
|
await os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.noSuchUser,
|
||||||
|
text: i18n.ts.signinFailed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await misskeyApi('signin', {
|
||||||
|
username: userInfo.value.username,
|
||||||
|
password: password.value,
|
||||||
|
token,
|
||||||
|
}).then(async (res) => {
|
||||||
|
emit('login', res);
|
||||||
|
await onLoginSucceeded(res);
|
||||||
|
}).catch(onLoginFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLoginSucceeded(res: Misskey.entities.SigninResponse) {
|
||||||
|
if (props.autoSet) {
|
||||||
|
await login(res.i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoginFailed(err?: any): void {
|
||||||
|
const id = err?.id ?? null;
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
|
case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
|
||||||
os.alert({
|
os.alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
@ -278,6 +292,14 @@ function loginFailed(err: any): void {
|
||||||
});
|
});
|
||||||
break;
|
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': {
|
case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': {
|
||||||
os.alert({
|
os.alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
@ -286,6 +308,14 @@ function loginFailed(err: any): void {
|
||||||
});
|
});
|
||||||
break;
|
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': {
|
case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': {
|
||||||
os.alert({
|
os.alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
@ -312,113 +342,42 @@ function loginFailed(err: any): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
totpLogin.value = false;
|
doingPasskeyFromInputPage.value = false;
|
||||||
signing.value = false;
|
waiting.value = false;
|
||||||
}
|
|
||||||
|
|
||||||
function resetPassword(): void {
|
|
||||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
|
|
||||||
closed: () => dispose(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.avatar {
|
.transition_enterActive,
|
||||||
margin: 0 auto 0 auto;
|
.transition_leaveActive {
|
||||||
width: 64px;
|
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
|
||||||
height: 64px;
|
}
|
||||||
background: #ddd;
|
.transition_enterFrom {
|
||||||
background-position: center;
|
opacity: 0;
|
||||||
background-size: cover;
|
transform: translateX(50px);
|
||||||
border-radius: 100%;
|
}
|
||||||
|
.transition_leaveTo {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceManualSelectButton {
|
.signinRoot {
|
||||||
display: block;
|
overflow-x: hidden;
|
||||||
text-align: center;
|
overflow-x: clip;
|
||||||
opacity: .7;
|
|
||||||
font-size: .8em;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.orHr {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: .4em auto;
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
background: var(--divider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.orMsg {
|
.waitingRoot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -.6em;
|
top: 0;
|
||||||
display: inline-block;
|
left: 0;
|
||||||
padding: 0 1em;
|
width: 100%;
|
||||||
background: var(--panel);
|
height: 100%;
|
||||||
font-size: 0.8em;
|
background-color: color-mix(in srgb, var(--panel), transparent 50%);
|
||||||
color: var(--fgOnPanel);
|
display: flex;
|
||||||
margin: 0;
|
justify-content: center;
|
||||||
left: 50%;
|
align-items: center;
|
||||||
transform: translateX(-50%);
|
z-index: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5177,6 +5177,8 @@ export type operations = {
|
||||||
urlPreviewRequireContentLength: boolean;
|
urlPreviewRequireContentLength: boolean;
|
||||||
urlPreviewUserAgent: string | null;
|
urlPreviewUserAgent: string | null;
|
||||||
urlPreviewSummaryProxyUrl: string | null;
|
urlPreviewSummaryProxyUrl: string | null;
|
||||||
|
federation: string;
|
||||||
|
federationHosts: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -9428,6 +9430,9 @@ export type operations = {
|
||||||
urlPreviewRequireContentLength?: boolean;
|
urlPreviewRequireContentLength?: boolean;
|
||||||
urlPreviewUserAgent?: string | null;
|
urlPreviewUserAgent?: string | null;
|
||||||
urlPreviewSummaryProxyUrl?: string | null;
|
urlPreviewSummaryProxyUrl?: string | null;
|
||||||
|
/** @enum {string} */
|
||||||
|
federation?: 'all' | 'none' | 'specified';
|
||||||
|
federationHosts?: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue