summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-10-04 15:23:33 +0900
committerGitHub <noreply@github.com>2024-10-04 15:23:33 +0900
commit975c2e7bc567618c3f8b0082afcba6530d679dae (patch)
tree2268801e42af4285f851b08077bbe9d569755156 /packages
parentUpdate generate.tsx (diff)
downloadmisskey-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')
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts13
-rw-r--r--packages/backend/src/models/json-schema/user.ts42
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts86
-rw-r--r--packages/backend/test/e2e/2fa.ts99
-rw-r--r--packages/backend/test/e2e/users.ts15
-rw-r--r--packages/frontend/src/components/MkSignin.input.vue206
-rw-r--r--packages/frontend/src/components/MkSignin.passkey.vue92
-rw-r--r--packages/frontend/src/components/MkSignin.password.vue181
-rw-r--r--packages/frontend/src/components/MkSignin.totp.vue74
-rw-r--r--packages/frontend/src/components/MkSignin.vue688
-rw-r--r--packages/frontend/src/components/MkSigninDialog.vue80
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md2
-rw-r--r--packages/misskey-js/src/autogen/types.ts15
-rw-r--r--packages/misskey-js/src/entities.ts2
14 files changed, 1125 insertions, 470 deletions
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 69e2d6fc89..c9939adf11 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -545,11 +545,6 @@ export class UserEntityService implements OnModuleInit {
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility,
- twoFactorEnabled: profile!.twoFactorEnabled,
- usePasswordLessLogin: profile!.usePasswordLessLogin,
- securityKeys: profile!.twoFactorEnabled
- ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
- : false,
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id,
name: role.name,
@@ -564,6 +559,14 @@ export class UserEntityService implements OnModuleInit {
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
} : {}),
+ ...(isDetailed && (isMe || iAmModerator) ? {
+ twoFactorEnabled: profile!.twoFactorEnabled,
+ usePasswordLessLogin: profile!.usePasswordLessLogin,
+ securityKeys: profile!.twoFactorEnabled
+ ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
+ : false,
+ } : {}),
+
...(isDetailed && isMe ? {
avatarId: user.avatarId,
bannerId: user.bannerId,
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 16c8a5a097..9cffd680f2 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -346,21 +346,6 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
enum: ['public', 'followers', 'private'],
},
- twoFactorEnabled: {
- type: 'boolean',
- nullable: false, optional: false,
- default: false,
- },
- usePasswordLessLogin: {
- type: 'boolean',
- nullable: false, optional: false,
- default: false,
- },
- securityKeys: {
- type: 'boolean',
- nullable: false, optional: false,
- default: false,
- },
roles: {
type: 'array',
nullable: false, optional: false,
@@ -382,6 +367,18 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'string',
nullable: false, optional: true,
},
+ twoFactorEnabled: {
+ type: 'boolean',
+ nullable: false, optional: true,
+ },
+ usePasswordLessLogin: {
+ type: 'boolean',
+ nullable: false, optional: true,
+ },
+ securityKeys: {
+ type: 'boolean',
+ nullable: false, optional: true,
+ },
//#region relations
isFollowing: {
type: 'boolean',
@@ -630,6 +627,21 @@ export const packedMeDetailedOnlySchema = {
nullable: false, optional: false,
ref: 'RolePolicies',
},
+ twoFactorEnabled: {
+ type: 'boolean',
+ nullable: false, optional: false,
+ default: false,
+ },
+ usePasswordLessLogin: {
+ type: 'boolean',
+ nullable: false, optional: false,
+ default: false,
+ },
+ securityKeys: {
+ type: 'boolean',
+ nullable: false, optional: false,
+ default: false,
+ },
//#region secrets
email: {
type: 'string',
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 2ccc75da00..81684beb3c 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -12,6 +12,7 @@ import type {
MiMeta,
SigninsRepository,
UserProfilesRepository,
+ UserSecurityKeysRepository,
UsersRepository,
} from '@/models/_.js';
import type { Config } from '@/config.js';
@@ -25,9 +26,27 @@ import { CaptchaService } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
-import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
+import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import type { FastifyReply, FastifyRequest } from 'fastify';
+/**
+ * next を指定すると、次にクライアント側で行うべき処理を指定できる。
+ *
+ * - `captcha`: パスワードと、(有効になっている場合は)CAPTCHAを求める
+ * - `password`: パスワードを求める
+ * - `totp`: ワンタイムパスワードを求める
+ * - `passkey`: WebAuthn認証を求める(WebAuthnに対応していないブラウザの場合はワンタイムパスワード)
+ */
+
+type SigninErrorResponse = {
+ id: string;
+ next?: 'captcha' | 'password' | 'totp';
+} | {
+ id: string;
+ next: 'passkey';
+ authRequest: PublicKeyCredentialRequestOptionsJSON;
+};
+
@Injectable()
export class SigninApiService {
constructor(
@@ -43,6 +62,9 @@ export class SigninApiService {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
+ @Inject(DI.userSecurityKeysRepository)
+ private userSecurityKeysRepository: UserSecurityKeysRepository,
+
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
@@ -60,7 +82,7 @@ export class SigninApiService {
request: FastifyRequest<{
Body: {
username: string;
- password: string;
+ password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string;
@@ -79,7 +101,7 @@ export class SigninApiService {
const password = body['password'];
const token = body['token'];
- function error(status: number, error: { id: string }) {
+ function error(status: number, error: SigninErrorResponse) {
reply.code(status);
return { error };
}
@@ -103,11 +125,6 @@ export class SigninApiService {
return;
}
- if (typeof password !== 'string') {
- reply.code(400);
- return;
- }
-
if (token != null && typeof token !== 'string') {
reply.code(400);
return;
@@ -132,11 +149,36 @@ export class SigninApiService {
}
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
+ const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1);
+
+ if (password == null) {
+ reply.code(403);
+ if (profile.twoFactorEnabled) {
+ return {
+ error: {
+ id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
+ next: 'password',
+ },
+ } satisfies { error: SigninErrorResponse };
+ } else {
+ return {
+ error: {
+ id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
+ next: 'captcha',
+ },
+ } satisfies { error: SigninErrorResponse };
+ }
+ }
+
+ if (typeof password !== 'string') {
+ reply.code(400);
+ return;
+ }
// Compare password
const same = await bcrypt.compare(password, profile.password!);
- const fail = async (status?: number, failure?: { id: string }) => {
+ const fail = async (status?: number, failure?: SigninErrorResponse) => {
// Append signin history
await this.signinsRepository.insert({
id: this.idService.gen(),
@@ -217,7 +259,7 @@ export class SigninApiService {
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
});
}
- } else {
+ } else if (securityKeysAvailable) {
if (!same && !profile.usePasswordLessLogin) {
return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
@@ -226,8 +268,28 @@ export class SigninApiService {
const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
- reply.code(200);
- return authRequest;
+ reply.code(403);
+ return {
+ error: {
+ id: '06e661b9-8146-4ae3-bde5-47138c0ae0c4',
+ next: 'passkey',
+ authRequest,
+ },
+ } satisfies { error: SigninErrorResponse };
+ } else {
+ if (!same || !profile.twoFactorEnabled) {
+ return await fail(403, {
+ id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
+ });
+ } else {
+ reply.code(403);
+ return {
+ error: {
+ id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
+ next: 'totp',
+ },
+ } satisfies { error: SigninErrorResponse };
+ }
}
// never get here
}
diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts
index 06548fa7da..88c32b4346 100644
--- a/packages/backend/test/e2e/2fa.ts
+++ b/packages/backend/test/e2e/2fa.ts
@@ -136,13 +136,7 @@ describe('2要素認証', () => {
keyName: string,
credentialId: Buffer,
requestOptions: PublicKeyCredentialRequestOptionsJSON,
- }): {
- username: string,
- password: string,
- credential: AuthenticationResponseJSON,
- 'g-recaptcha-response'?: string | null,
- 'hcaptcha-response'?: string | null,
- } => {
+ }): misskey.entities.SigninRequest => {
// AuthenticatorAssertionResponse.authenticatorData
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
const authenticatorData = Buffer.concat([
@@ -202,11 +196,16 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(doneResponse.status, 200);
- const usersShowResponse = await api('users/show', {
- username,
- }, alice);
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true);
+ const signinWithoutTokenResponse = await api('signin', {
+ ...signinParam(),
+ });
+ assert.strictEqual(signinWithoutTokenResponse.status, 403);
+ assert.deepStrictEqual(signinWithoutTokenResponse.body, {
+ error: {
+ id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
+ next: 'totp',
+ },
+ });
const signinResponse = await api('signin', {
...signinParam(),
@@ -253,26 +252,28 @@ describe('2要素認証', () => {
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName);
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true);
-
const signinResponse = await api('signin', {
...signinParam(),
});
- assert.strictEqual(signinResponse.status, 200);
- assert.strictEqual(signinResponse.body.i, undefined);
- assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined);
- assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined);
- assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url'));
+ const signinResponseBody = signinResponse.body as unknown as {
+ error: {
+ id: string;
+ next: 'passkey';
+ authRequest: PublicKeyCredentialRequestOptionsJSON;
+ };
+ };
+ assert.strictEqual(signinResponse.status, 403);
+ assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4');
+ assert.strictEqual(signinResponseBody.error.next, 'passkey');
+ assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined);
+ assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined);
+ assert.strictEqual(signinResponseBody.error.authRequest.allowCredentials && signinResponseBody.error.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url'));
const signinResponse2 = await api('signin', signinWithSecurityKeyParam({
keyName,
credentialId,
- requestOptions: signinResponse.body,
- } as any));
+ requestOptions: signinResponseBody.error.authRequest,
+ }));
assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined);
@@ -315,24 +316,32 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(passwordLessResponse.status, 204);
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true);
+ const iResponse = await api('i', {}, alice);
+ assert.strictEqual(iResponse.status, 200);
+ assert.strictEqual(iResponse.body.usePasswordLessLogin, true);
const signinResponse = await api('signin', {
...signinParam(),
password: '',
});
- assert.strictEqual(signinResponse.status, 200);
- assert.strictEqual(signinResponse.body.i, undefined);
+ const signinResponseBody = signinResponse.body as unknown as {
+ error: {
+ id: string;
+ next: 'passkey';
+ authRequest: PublicKeyCredentialRequestOptionsJSON;
+ };
+ };
+ assert.strictEqual(signinResponse.status, 403);
+ assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4');
+ assert.strictEqual(signinResponseBody.error.next, 'passkey');
+ assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined);
+ assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined);
const signinResponse2 = await api('signin', {
...signinWithSecurityKeyParam({
keyName,
credentialId,
- requestOptions: signinResponse.body,
+ requestOptions: signinResponseBody.error.authRequest,
} as any),
password: '',
});
@@ -424,11 +433,11 @@ describe('2要素認証', () => {
assert.strictEqual(keyDoneResponse.status, 200);
// テストの実行順によっては複数残ってるので全部消す
- const iResponse = await api('i', {
+ const beforeIResponse = await api('i', {
}, alice);
- assert.strictEqual(iResponse.status, 200);
- assert.ok(iResponse.body.securityKeysList);
- for (const key of iResponse.body.securityKeysList) {
+ assert.strictEqual(beforeIResponse.status, 200);
+ assert.ok(beforeIResponse.body.securityKeysList);
+ for (const key of beforeIResponse.body.securityKeysList) {
const removeKeyResponse = await api('i/2fa/remove-key', {
token: otpToken(registerResponse.body.secret),
password,
@@ -437,11 +446,9 @@ describe('2要素認証', () => {
assert.strictEqual(removeKeyResponse.status, 200);
}
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false);
+ const afterIResponse = await api('i', {}, alice);
+ assert.strictEqual(afterIResponse.status, 200);
+ assert.strictEqual(afterIResponse.body.securityKeys, false);
const signinResponse = await api('signin', {
...signinParam(),
@@ -468,11 +475,9 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(doneResponse.status, 200);
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true);
+ const iResponse = await api('i', {}, alice);
+ assert.strictEqual(iResponse.status, 200);
+ assert.strictEqual(iResponse.body.twoFactorEnabled, true);
const unregisterResponse = await api('i/2fa/unregister', {
token: otpToken(registerResponse.body.secret),
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index 8ebe9af792..822ca14ae6 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -83,9 +83,6 @@ describe('ユーザー', () => {
publicReactions: user.publicReactions,
followingVisibility: user.followingVisibility,
followersVisibility: user.followersVisibility,
- twoFactorEnabled: user.twoFactorEnabled,
- usePasswordLessLogin: user.usePasswordLessLogin,
- securityKeys: user.securityKeys,
roles: user.roles,
memo: user.memo,
});
@@ -149,6 +146,9 @@ describe('ユーザー', () => {
achievements: user.achievements,
loggedInDays: user.loggedInDays,
policies: user.policies,
+ twoFactorEnabled: user.twoFactorEnabled,
+ usePasswordLessLogin: user.usePasswordLessLogin,
+ securityKeys: user.securityKeys,
...(security ? {
email: user.email,
emailVerified: user.emailVerified,
@@ -343,9 +343,6 @@ describe('ユーザー', () => {
assert.strictEqual(response.publicReactions, true);
assert.strictEqual(response.followingVisibility, 'public');
assert.strictEqual(response.followersVisibility, 'public');
- assert.strictEqual(response.twoFactorEnabled, false);
- assert.strictEqual(response.usePasswordLessLogin, false);
- assert.strictEqual(response.securityKeys, false);
assert.deepStrictEqual(response.roles, []);
assert.strictEqual(response.memo, null);
@@ -385,6 +382,9 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0);
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
+ assert.strictEqual(response.twoFactorEnabled, false);
+ assert.strictEqual(response.usePasswordLessLogin, false);
+ assert.strictEqual(response.securityKeys, false);
assert.notStrictEqual(response.email, undefined);
assert.strictEqual(response.emailVerified, false);
assert.deepStrictEqual(response.securityKeysList, []);
@@ -618,6 +618,9 @@ describe('ユーザー', () => {
{ label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator },
// @ts-expect-error UserDetailedNotMe doesn't include isModerator
{ label: '自分以外から見たときはModeratorか判定できない', user: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.isModerator, expected: () => undefined },
+ { label: '自分から見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => alice, selector: (user: misskey.entities.MeDetailed) => user.twoFactorEnabled, expected: () => false },
+ { label: '自分以外から見た場合に二要素認証関連のプロパティがセットされていない', user: () => alice, me: () => bob, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => undefined },
+ { label: 'モデレーターから見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => false },
{ label: 'サイレンスになっている', user: () => userSilenced, selector: (user: misskey.entities.UserDetailed) => user.isSilenced },
// FIXME: 落ちる
//{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended },
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>
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 5f4792eb74..9ad784c296 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -3040,7 +3040,7 @@ type Signin = components['schemas']['Signin'];
// @public (undocumented)
type SigninRequest = {
username: string;
- password: string;
+ password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null;
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 32646d28ed..3876a0bfe5 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3782,16 +3782,13 @@ export type components = {
followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */
followersVisibility: 'public' | 'followers' | 'private';
- /** @default false */
- twoFactorEnabled: boolean;
- /** @default false */
- usePasswordLessLogin: boolean;
- /** @default false */
- securityKeys: boolean;
roles: components['schemas']['RoleLite'][];
followedMessage?: string | null;
memo: string | null;
moderationNote?: string;
+ twoFactorEnabled?: boolean;
+ usePasswordLessLogin?: boolean;
+ securityKeys?: boolean;
isFollowing?: boolean;
isFollowed?: boolean;
hasPendingFollowRequestFromYou?: boolean;
@@ -3972,6 +3969,12 @@ export type components = {
}[];
loggedInDays: number;
policies: components['schemas']['RolePolicies'];
+ /** @default false */
+ twoFactorEnabled: boolean;
+ /** @default false */
+ usePasswordLessLogin: boolean;
+ /** @default false */
+ securityKeys: boolean;
email?: string | null;
emailVerified?: boolean | null;
securityKeysList?: {
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 36b7f5bca3..98ac50e5a1 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -269,7 +269,7 @@ export type SignupPendingResponse = {
export type SigninRequest = {
username: string;
- password: string;
+ password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null;