Merge branch 'misskey-dev:develop' into dev
This commit is contained in:
commit
73ae524e9c
101 changed files with 2852 additions and 1531 deletions
BIN
packages/backend/assets/tabler-badges/login-2.png
Normal file
BIN
packages/backend/assets/tabler-badges/login-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
|
|
@ -71,20 +71,20 @@
|
|||
"@bull-board/fastify": "6.0.0",
|
||||
"@bull-board/ui": "6.0.0",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@fastify/accepts": "5.0.0",
|
||||
"@fastify/cookie": "10.0.0",
|
||||
"@fastify/cors": "10.0.0",
|
||||
"@fastify/express": "4.0.0",
|
||||
"@fastify/accepts": "5.0.1",
|
||||
"@fastify/cookie": "10.0.1",
|
||||
"@fastify/cors": "10.0.1",
|
||||
"@fastify/express": "4.0.1",
|
||||
"@fastify/http-proxy": "10.0.0",
|
||||
"@fastify/multipart": "9.0.0",
|
||||
"@fastify/static": "8.0.0",
|
||||
"@fastify/view": "10.0.0",
|
||||
"@fastify/multipart": "9.0.1",
|
||||
"@fastify/static": "8.0.1",
|
||||
"@fastify/view": "10.0.1",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.1.0",
|
||||
"@napi-rs/canvas": "0.1.56",
|
||||
"@nestjs/common": "10.4.3",
|
||||
"@nestjs/core": "10.4.3",
|
||||
"@nestjs/testing": "10.4.3",
|
||||
"@nestjs/common": "10.4.4",
|
||||
"@nestjs/core": "10.4.4",
|
||||
"@nestjs/testing": "10.4.4",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "8.20.0",
|
||||
"@sentry/profiling-node": "8.20.0",
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.13.2",
|
||||
"bullmq": "5.15.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.3.0",
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
"oauth2orize": "1.12.0",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.3.2",
|
||||
"otpauth": "9.3.4",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.13.0",
|
||||
"pkce-challenge": "4.1.0",
|
||||
|
|
@ -166,7 +166,7 @@
|
|||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
"sanitize-html": "2.13.0",
|
||||
"sanitize-html": "2.13.1",
|
||||
"secure-json-parse": "2.7.0",
|
||||
"sharp": "0.33.5",
|
||||
"slacc": "0.0.10",
|
||||
|
|
@ -187,14 +187,14 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@nestjs/platform-express": "10.4.3",
|
||||
"@nestjs/platform-express": "10.4.4",
|
||||
"@simplewebauthn/types": "10.0.0",
|
||||
"@swc/jest": "0.2.36",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "6.0.2",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/body-parser": "1.19.5",
|
||||
"@types/color-convert": "2.0.3",
|
||||
"@types/color-convert": "2.0.4",
|
||||
"@types/content-disposition": "0.5.8",
|
||||
"@types/fluent-ffmpeg": "2.1.26",
|
||||
"@types/htmlescape": "1.1.3",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ type Source = {
|
|||
|
||||
publishTarballInsteadOfProvideRepositoryUrl?: boolean;
|
||||
|
||||
setupPassword?: string;
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
|
@ -152,6 +154,7 @@ export type Config = {
|
|||
|
||||
version: string;
|
||||
publishTarballInsteadOfProvideRepositoryUrl: boolean;
|
||||
setupPassword: string | undefined;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
|
|
@ -232,6 +235,7 @@ export function loadConfig(): Config {
|
|||
return {
|
||||
version,
|
||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
setupPassword: config.setupPassword,
|
||||
url: url.origin,
|
||||
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
|
||||
socket: config.socket,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { userExportableEntities } from '@/types.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiNote } from './Note.js';
|
||||
import { MiAccessToken } from './AccessToken.js';
|
||||
import { MiRole } from './Role.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
import { userExportableEntities } from '@/types.js';
|
||||
|
||||
export type MiNotification = {
|
||||
type: 'note';
|
||||
|
|
@ -86,6 +86,10 @@ export type MiNotification = {
|
|||
createdAt: string;
|
||||
exportedEntity: typeof userExportableEntities[number];
|
||||
fileId: MiDriveFile['id'];
|
||||
} | {
|
||||
type: 'login';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
} | {
|
||||
type: 'app';
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -322,6 +322,16 @@ export const packedNotificationSchema = {
|
|||
format: 'id',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['login'],
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export class ApiServerService {
|
|||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
}
|
||||
}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
|
||||
|
||||
|
|
@ -126,17 +127,18 @@ export class ApiServerService {
|
|||
username: string;
|
||||
password: string;
|
||||
token?: string;
|
||||
signature?: string;
|
||||
authenticatorData?: string;
|
||||
clientDataJSON?: string;
|
||||
credentialId?: string;
|
||||
challengeId?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
};
|
||||
}>('/signin', (request, reply) => this.signinApiService.signin(request, reply));
|
||||
|
||||
fastify.post<{
|
||||
Body: {
|
||||
credential?: AuthenticationResponseJSON;
|
||||
context?: string;
|
||||
};
|
||||
}>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply));
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import * as OTPAuth from 'otpauth';
|
|||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
MiMeta,
|
||||
SigninsRepository,
|
||||
UserProfilesRepository,
|
||||
UserSecurityKeysRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -20,23 +22,49 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||
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(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
@Inject(DI.signinsRepository)
|
||||
private signinsRepository: SigninsRepository,
|
||||
|
||||
|
|
@ -45,6 +73,7 @@ export class SigninApiService {
|
|||
private signinService: SigninService,
|
||||
private userAuthService: UserAuthService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
private captchaService: CaptchaService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -53,9 +82,13 @@ export class SigninApiService {
|
|||
request: FastifyRequest<{
|
||||
Body: {
|
||||
username: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
|
|
@ -68,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 };
|
||||
}
|
||||
|
|
@ -92,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;
|
||||
|
|
@ -121,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(),
|
||||
|
|
@ -139,6 +192,32 @@ export class SigninApiService {
|
|||
};
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
|
||||
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
|
||||
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
|
||||
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
|
||||
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (same) {
|
||||
return this.signinService.signin(request, reply, user);
|
||||
} else {
|
||||
|
|
@ -180,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',
|
||||
|
|
@ -189,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { SigninsRepository } from '@/models/_.js';
|
||||
import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { SigninEntityService } from '@/core/entities/SigninEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -19,7 +21,12 @@ export class SigninService {
|
|||
@Inject(DI.signinsRepository)
|
||||
private signinsRepository: SigninsRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private signinEntityService: SigninEntityService,
|
||||
private emailService: EmailService,
|
||||
private notificationService: NotificationService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
|
|
@ -28,7 +35,8 @@ export class SigninService {
|
|||
@bindThis
|
||||
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
|
||||
setImmediate(async () => {
|
||||
// Append signin history
|
||||
this.notificationService.createNotification(user.id, 'login', {});
|
||||
|
||||
const record = await this.signinsRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
userId: user.id,
|
||||
|
|
@ -37,8 +45,14 @@ export class SigninService {
|
|||
success: true,
|
||||
});
|
||||
|
||||
// Publish signin event
|
||||
this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record));
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
if (profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, 'New login / ログインがありました',
|
||||
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。',
|
||||
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。');
|
||||
}
|
||||
});
|
||||
|
||||
reply.code(200);
|
||||
|
|
|
|||
|
|
@ -12,11 +12,27 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
errors: {
|
||||
accessDenied: {
|
||||
message: 'Access denied.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
||||
},
|
||||
|
||||
wrongInitialPassword: {
|
||||
message: 'Initial password is incorrect.',
|
||||
code: 'INCORRECT_INITIAL_PASSWORD',
|
||||
id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -35,6 +51,7 @@ export const paramDef = {
|
|||
properties: {
|
||||
username: localUsernameSchema,
|
||||
password: passwordSchema,
|
||||
setupPassword: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['username', 'password'],
|
||||
} as const;
|
||||
|
|
@ -42,6 +59,9 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -52,7 +72,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, _me, token) => {
|
||||
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
|
||||
const realUsers = await this.instanceActorService.realLocalUsersPresent();
|
||||
if ((realUsers && !me?.isRoot) || token !== null) throw new Error('access denied');
|
||||
|
||||
if (!realUsers && me == null && token == null) {
|
||||
// 初回セットアップの場合
|
||||
if (this.config.setupPassword != null) {
|
||||
// 初期パスワードが設定されている場合
|
||||
if (ps.setupPassword !== this.config.setupPassword) {
|
||||
// 初期パスワードが違う場合
|
||||
throw new ApiError(meta.errors.wrongInitialPassword);
|
||||
}
|
||||
} else if (ps.setupPassword != null && ps.setupPassword.trim() !== '') {
|
||||
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
|
||||
throw new ApiError(meta.errors.wrongInitialPassword);
|
||||
}
|
||||
} else if ((realUsers && !me?.isRoot) || token !== null) {
|
||||
// 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
const { account, secret } = await this.signupService.signup({
|
||||
username: ps.username,
|
||||
|
|
|
|||
|
|
@ -661,7 +661,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
if (Array.isArray(ps.federationHosts)) {
|
||||
set.blockedHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
* roleAssigned - ロールが付与された
|
||||
* achievementEarned - 実績を獲得
|
||||
* exportCompleted - エクスポートが完了
|
||||
* login - ログイン
|
||||
* app - アプリ通知
|
||||
* test - テスト通知(サーバー側)
|
||||
*/
|
||||
|
|
@ -34,6 +35,7 @@ export const notificationTypes = [
|
|||
'roleAssigned',
|
||||
'achievementEarned',
|
||||
'exportCompleted',
|
||||
'login',
|
||||
'app',
|
||||
'test',
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"@tabler/icons-webfont": "3.3.0",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.1.4",
|
||||
"@vue/compiler-sfc": "3.5.10",
|
||||
"@vue/compiler-sfc": "3.5.11",
|
||||
"astring": "1.9.0",
|
||||
"buraha": "0.0.1",
|
||||
"estree-walker": "3.0.3",
|
||||
|
|
@ -27,8 +27,8 @@
|
|||
"frontend-shared": "workspace:*",
|
||||
"punycode": "2.3.1",
|
||||
"rollup": "4.22.5",
|
||||
"sass": "1.79.3",
|
||||
"shiki": "1.12.0",
|
||||
"sass": "1.79.4",
|
||||
"shiki": "1.21.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
"uuid": "10.0.0",
|
||||
"json5": "2.2.3",
|
||||
"vite": "5.4.8",
|
||||
"vue": "3.5.10"
|
||||
"vue": "3.5.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.1.0",
|
||||
|
|
@ -51,10 +51,10 @@
|
|||
"@typescript-eslint/eslint-plugin": "7.17.0",
|
||||
"@typescript-eslint/parser": "7.17.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"@vue/runtime-core": "3.5.10",
|
||||
"@vue/runtime-core": "3.5.11",
|
||||
"acorn": "8.12.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.30.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "9.28.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"happy-dom": "10.0.3",
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ const props = defineProps<{
|
|||
host?: string | null;
|
||||
url?: string;
|
||||
useOriginalSize?: boolean;
|
||||
menu?: boolean;
|
||||
menuReaction?: boolean;
|
||||
fallbackToImage?: boolean;
|
||||
}>();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { VNode, h, SetupContext, provide } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import EmUrl from '@/components/EmUrl.vue';
|
||||
import EmTime from '@/components/EmTime.vue';
|
||||
import EmLink from '@/components/EmLink.vue';
|
||||
|
|
@ -13,7 +14,6 @@ import EmMention from '@/components/EmMention.vue';
|
|||
import EmEmoji from '@/components/EmEmoji.vue';
|
||||
import EmCustomEmoji from '@/components/EmCustomEmoji.vue';
|
||||
import EmA from '@/components/EmA.vue';
|
||||
import { host } from '@@/js/config.js';
|
||||
|
||||
function safeParseFloat(str: unknown): number | null {
|
||||
if (typeof str !== 'string' || str === '') return null;
|
||||
|
|
@ -41,9 +41,6 @@ type MfmProps = {
|
|||
rootScale?: number;
|
||||
nyaize?: boolean | 'respect';
|
||||
parsedNodes?: mfm.MfmNode[] | null;
|
||||
enableEmojiMenu?: boolean;
|
||||
enableEmojiMenuReaction?: boolean;
|
||||
linkNavigationBehavior?: string;
|
||||
};
|
||||
|
||||
type MfmEvents = {
|
||||
|
|
@ -52,8 +49,6 @@ type MfmEvents = {
|
|||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) {
|
||||
provide('linkNavigationBehavior', props.linkNavigationBehavior);
|
||||
|
||||
const isNote = props.isNote ?? true;
|
||||
const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
|
||||
|
||||
|
|
@ -397,8 +392,6 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
normal: props.plain,
|
||||
host: null,
|
||||
useOriginalSize: scale >= 2.5,
|
||||
menu: props.enableEmojiMenu,
|
||||
menuReaction: props.enableEmojiMenuReaction,
|
||||
fallbackToImage: false,
|
||||
})];
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ export function getConfig(): UserConfig {
|
|||
}
|
||||
},
|
||||
},
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
define: {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export const notificationTypes = [
|
|||
'roleAssigned',
|
||||
'achievementEarned',
|
||||
'exportCompleted',
|
||||
'login',
|
||||
'test',
|
||||
'app',
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -397,7 +397,18 @@ function toStories(component: string): Promise<string> {
|
|||
const globs = await Promise.all([
|
||||
glob('src/components/global/Mk*.vue'),
|
||||
glob('src/components/global/RouterView.vue'),
|
||||
glob('src/components/Mk[A-E]*.vue'),
|
||||
glob('src/components/MkAbuseReportWindow.vue'),
|
||||
glob('src/components/MkAccountMoved.vue'),
|
||||
glob('src/components/MkAchievements.vue'),
|
||||
glob('src/components/MkAnalogClock.vue'),
|
||||
glob('src/components/MkAnimBg.vue'),
|
||||
glob('src/components/MkAnnouncementDialog.vue'),
|
||||
glob('src/components/MkAntennaEditor.vue'),
|
||||
glob('src/components/MkAntennaEditorDialog.vue'),
|
||||
glob('src/components/MkAsUi.vue'),
|
||||
glob('src/components/MkAutocomplete.vue'),
|
||||
glob('src/components/MkAvatars.vue'),
|
||||
glob('src/components/Mk[B-E]*.vue'),
|
||||
glob('src/components/MkFlashPreview.vue'),
|
||||
glob('src/components/MkGalleryPostPreview.vue'),
|
||||
glob('src/components/MkSignupServerRules.vue'),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"@tabler/icons-webfont": "3.3.0",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.1.4",
|
||||
"@vue/compiler-sfc": "3.5.10",
|
||||
"@vue/compiler-sfc": "3.5.11",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11",
|
||||
"astring": "1.9.0",
|
||||
"broadcast-channel": "7.0.0",
|
||||
|
|
@ -39,12 +39,13 @@
|
|||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "11.10.4",
|
||||
"chromatic": "11.11.0",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "2.0.0-rc.2",
|
||||
"date-fns": "2.30.0",
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"frontend-shared": "workspace:*",
|
||||
"idb-keyval": "6.2.1",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
|
|
@ -54,13 +55,12 @@
|
|||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"frontend-shared": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode": "2.3.1",
|
||||
"rollup": "4.22.5",
|
||||
"sanitize-html": "2.13.0",
|
||||
"sanitize-html": "2.13.1",
|
||||
"sass": "1.79.3",
|
||||
"shiki": "1.12.0",
|
||||
"shiki": "1.21.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.169.0",
|
||||
|
|
@ -72,30 +72,31 @@
|
|||
"uuid": "10.0.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "5.4.8",
|
||||
"vue": "3.5.10",
|
||||
"vue": "3.5.11",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.1.0",
|
||||
"@storybook/addon-actions": "8.3.3",
|
||||
"@storybook/addon-essentials": "8.3.3",
|
||||
"@storybook/addon-interactions": "8.3.3",
|
||||
"@storybook/addon-links": "8.3.3",
|
||||
"@storybook/addon-mdx-gfm": "8.3.3",
|
||||
"@storybook/addon-storysource": "8.3.3",
|
||||
"@storybook/blocks": "8.3.3",
|
||||
"@storybook/components": "8.3.3",
|
||||
"@storybook/core-events": "8.3.3",
|
||||
"@storybook/manager-api": "8.3.3",
|
||||
"@storybook/preview-api": "8.3.3",
|
||||
"@storybook/react": "8.3.3",
|
||||
"@storybook/react-vite": "8.3.3",
|
||||
"@storybook/test": "8.3.3",
|
||||
"@storybook/theming": "8.3.3",
|
||||
"@storybook/types": "8.3.3",
|
||||
"@storybook/vue3": "8.3.3",
|
||||
"@storybook/vue3-vite": "8.3.3",
|
||||
"@storybook/addon-actions": "8.3.4",
|
||||
"@storybook/addon-essentials": "8.3.4",
|
||||
"@storybook/addon-interactions": "8.3.4",
|
||||
"@storybook/addon-links": "8.3.4",
|
||||
"@storybook/addon-mdx-gfm": "8.3.4",
|
||||
"@storybook/addon-storysource": "8.3.4",
|
||||
"@storybook/blocks": "8.3.4",
|
||||
"@storybook/components": "8.3.4",
|
||||
"@storybook/core-events": "8.3.4",
|
||||
"@storybook/manager-api": "8.3.4",
|
||||
"@storybook/preview-api": "8.3.4",
|
||||
"@storybook/react": "8.3.4",
|
||||
"@storybook/react-vite": "8.3.4",
|
||||
"@storybook/test": "8.3.4",
|
||||
"@storybook/theming": "8.3.4",
|
||||
"@storybook/types": "8.3.4",
|
||||
"@storybook/vue3": "8.3.4",
|
||||
"@storybook/vue3-vite": "8.3.4",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "^1.6.4",
|
||||
"@types/estree": "1.0.6",
|
||||
"@types/matter-js": "0.19.7",
|
||||
"@types/micromatch": "4.0.9",
|
||||
|
|
@ -110,11 +111,11 @@
|
|||
"@typescript-eslint/eslint-plugin": "7.17.0",
|
||||
"@typescript-eslint/parser": "7.17.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"@vue/runtime-core": "3.5.10",
|
||||
"@vue/runtime-core": "3.5.11",
|
||||
"acorn": "8.12.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.15.0",
|
||||
"eslint-plugin-import": "2.30.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "9.28.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"happy-dom": "10.0.3",
|
||||
|
|
@ -128,7 +129,7 @@
|
|||
"react-dom": "18.3.1",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.0.8",
|
||||
"storybook": "8.3.3",
|
||||
"storybook": "8.3.4",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "1.6.0",
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { abuseUserReport } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAbuseReport from './MkAbuseReport.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAbuseReport,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
resolved: action('resolved'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAbuseReport v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
report: abuseUserReport(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => {
|
||||
action('POST /api/admin/resolve-abuse-user-report')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAbuseReport>;
|
||||
|
|
@ -4,64 +4,99 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div class="bcekxzvu _margin _panel">
|
||||
<div class="target">
|
||||
<MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'">
|
||||
<MkAvatar class="avatar" :user="report.targetUser" indicator/>
|
||||
<div class="names">
|
||||
<MkUserName class="name" :user="report.targetUser"/>
|
||||
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
|
||||
</div>
|
||||
</MkA>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts.registeredDate }}</template>
|
||||
<template #value>{{ dateString(report.targetUser.createdAt) }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<div>
|
||||
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
|
||||
<MkFolder>
|
||||
<template #icon>
|
||||
<i v-if="report.resolved" class="ti ti-check" style="color: var(--success)"></i>
|
||||
<i v-else class="ti ti-exclamation-circle" style="color: var(--warn)"></i>
|
||||
</template>
|
||||
<template #label><MkAcct :user="report.targetUser"/> (by <MkAcct :user="report.reporter"/>)</template>
|
||||
<template #caption>{{ report.comment }}</template>
|
||||
<template #suffix><MkTime :time="report.createdAt"/></template>
|
||||
<template v-if="!report.resolved" #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
|
||||
<template v-if="report.targetUser.host == null || report.resolved">
|
||||
<MkButton primary @click="resolveAndForward">{{ i18n.ts.forwardReport }}</MkButton>
|
||||
<div v-tooltip:dialog="i18n.ts.forwardReportIsAnonymous" class="_button _help"><i class="ti ti-help-circle"></i></div>
|
||||
</template>
|
||||
</div>
|
||||
<hr/>
|
||||
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.root" class="_gaps_s">
|
||||
<MkFolder :withSpacer="false">
|
||||
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template>
|
||||
<template #label>Target: <MkAcct :user="report.targetUser"/></template>
|
||||
<template #suffix>#{{ report.targetUserId.toUpperCase() }}</template>
|
||||
|
||||
<div style="container-type: inline-size;">
|
||||
<RouterView :router="targetRouter"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #icon><i class="ti ti-message-2"></i></template>
|
||||
<template #label>{{ i18n.ts.details }}</template>
|
||||
<div>
|
||||
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :withSpacer="false">
|
||||
<template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template>
|
||||
<template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template>
|
||||
<template #suffix>#{{ report.reporterId.toUpperCase() }}</template>
|
||||
|
||||
<div style="container-type: inline-size;">
|
||||
<RouterView :router="reporterRouter"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<div v-if="report.assignee">
|
||||
{{ i18n.ts.moderator }}:
|
||||
<MkAcct :user="report.assignee"/>
|
||||
</div>
|
||||
<div><MkTime :time="report.createdAt"/></div>
|
||||
<div class="action">
|
||||
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
|
||||
{{ i18n.ts.forwardReport }}
|
||||
<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template>
|
||||
</MkSwitch>
|
||||
<MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { provide, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import RouterView from '@/components/global/RouterView.vue';
|
||||
import { useRouterFactory } from '@/router/supplier';
|
||||
|
||||
const props = defineProps<{
|
||||
report: any;
|
||||
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'resolved', reportId: string): void;
|
||||
}>();
|
||||
|
||||
const forward = ref(props.report.forwarded);
|
||||
const routerFactory = useRouterFactory();
|
||||
const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`);
|
||||
targetRouter.init();
|
||||
const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`);
|
||||
reporterRouter.init();
|
||||
|
||||
function resolve() {
|
||||
os.apiWithDialog('admin/resolve-abuse-user-report', {
|
||||
forward: forward.value,
|
||||
reportId: props.report.id,
|
||||
}).then(() => {
|
||||
emit('resolved', props.report.id);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAndForward() {
|
||||
os.apiWithDialog('admin/resolve-abuse-user-report', {
|
||||
forward: true,
|
||||
reportId: props.report.id,
|
||||
}).then(() => {
|
||||
emit('resolved', props.report.id);
|
||||
|
|
@ -69,47 +104,7 @@ function resolve() {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bcekxzvu {
|
||||
display: flex;
|
||||
|
||||
> .target {
|
||||
width: 35%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
border-right: solid 1px var(--divider);
|
||||
|
||||
> .info {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
--c: rgb(255 196 0 / 15%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
|
||||
background-size: 16px 16px;
|
||||
|
||||
> .avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
> .names {
|
||||
margin-left: 0.3em;
|
||||
padding: 0 8px;
|
||||
flex: 1;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .detail {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -38,9 +38,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer :marginMin="14" :marginMax="22">
|
||||
<MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" :class="$style.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
|
|
@ -59,9 +62,11 @@ import { defaultStore } from '@/store.js';
|
|||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
maxHeight?: number | null;
|
||||
withSpacer?: boolean;
|
||||
}>(), {
|
||||
defaultOpen: false,
|
||||
maxHeight: null,
|
||||
withSpacer: true,
|
||||
});
|
||||
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
|
|
|
|||
100
packages/frontend/src/components/MkFukidashi.vue
Normal file
100
packages/frontend/src/components/MkFukidashi.vue
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.root,
|
||||
tail === 'left' ? $style.left : $style.right,
|
||||
negativeMargin === true && $style.negativeMergin,
|
||||
shadow === true && $style.shadow,
|
||||
]"
|
||||
>
|
||||
<div :class="$style.bg">
|
||||
<svg v-if="tail !== 'none'" :class="$style.tail" version="1.1" viewBox="0 0 14.597 14.58" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-173.71 -87.184)">
|
||||
<path d="m188.19 87.657c-1.469 2.3218-3.9315 3.8312-6.667 4.0865-2.2309-1.7379-4.9781-2.6816-7.8061-2.6815h-5.1e-4v12.702h12.702v-5.1e-4c2e-5 -1.9998-0.47213-3.9713-1.378-5.754 2.0709-1.6834 3.2732-4.2102 3.273-6.8791-6e-5 -0.49375-0.0413-0.98662-0.1235-1.4735z" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-width=".33225" style="paint-order:stroke fill markers"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div :class="$style.content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
tail?: 'left' | 'right' | 'none';
|
||||
negativeMargin?: boolean;
|
||||
shadow?: boolean;
|
||||
}>(), {
|
||||
tail: 'right',
|
||||
negativeMargin: false,
|
||||
shadow: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
--fukidashi-radius: var(--radius);
|
||||
--fukidashi-bg: var(--panel);
|
||||
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-height: calc(var(--fukidashi-radius) * 2);
|
||||
padding-top: calc(var(--fukidashi-radius) * .13);
|
||||
|
||||
&.shadow {
|
||||
filter: drop-shadow(0 4px 32px var(--shadow));
|
||||
}
|
||||
|
||||
&.left {
|
||||
padding-left: calc(var(--fukidashi-radius) * .13);
|
||||
|
||||
&.negativeMergin {
|
||||
margin-left: calc(calc(var(--fukidashi-radius) * .13) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
padding-right: calc(var(--fukidashi-radius) * .13);
|
||||
|
||||
&.negativeMergin {
|
||||
margin-right: calc(calc(var(--fukidashi-radius) * .13) * -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--fukidashi-bg);
|
||||
border-radius: var(--fukidashi-radius);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.tail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: block;
|
||||
width: calc(var(--fukidashi-radius) * 1.13);
|
||||
height: auto;
|
||||
fill: var(--fukidashi-bg);
|
||||
}
|
||||
|
||||
.left .tail {
|
||||
left: 0;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.right .tail {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }" :behavior="navigationBehavior">
|
||||
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :behavior="navigationBehavior">
|
||||
<img :class="$style.icon" :src="avatarUrl" alt="">
|
||||
<span>
|
||||
<span>@{{ username }}</span>
|
||||
|
|
@ -16,7 +16,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { toUnicode } from 'punycode';
|
||||
import { computed } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { host as localHost } from '@@/js/config.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
|
@ -37,11 +36,7 @@ const isMe = $i && (
|
|||
`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
|
||||
);
|
||||
|
||||
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
|
||||
bg.setAlpha(0.1);
|
||||
const bgCss = bg.toRgbString();
|
||||
|
||||
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
|
||||
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar
|
||||
? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`)
|
||||
: `/avatar/@${props.username}@${props.host}`,
|
||||
);
|
||||
|
|
@ -53,9 +48,11 @@ const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
|
|||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 999px;
|
||||
color: var(--mention);
|
||||
background: color(from var(--mention) srgb r g b / 0.1);
|
||||
|
||||
&.isMe {
|
||||
color: var(--mentionMe);
|
||||
background: color(from var(--mentionMe) srgb r g b / 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<header :class="$style.root">
|
||||
<component :is="defaultStore.state.enableCondensedLine ? 'MkCondensedLine' : 'div'" :minScale="0.5" style="min-width: 0;">
|
||||
<component :is="defaultStore.state.enableCondensedLine ? 'MkCondensedLine' : 'div'" :minScale="0.7" style="min-width: 0;">
|
||||
<div style="display: flex; white-space: nowrap; align-items: baseline;">
|
||||
<div v-if="mock" :class="$style.name">
|
||||
<MkUserName :user="note.user"/>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.root">
|
||||
<div :class="$style.head">
|
||||
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
|
||||
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
|
||||
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
|
||||
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
|
||||
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
|
||||
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
|
||||
<MkAvatar v-else-if="notification.type === 'exportCompleted'" :class="$style.icon" :user="$i" link preview/>
|
||||
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
||||
<div
|
||||
:class="[$style.subIcon, {
|
||||
|
|
@ -27,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
[$style.t_pollEnded]: notification.type === 'pollEnded',
|
||||
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
|
||||
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
|
||||
[$style.t_login]: notification.type === 'login',
|
||||
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
|
||||
}]"
|
||||
>
|
||||
|
|
@ -40,6 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
|
||||
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
|
||||
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
|
||||
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
|
||||
<template v-else-if="notification.type === 'roleAssigned'">
|
||||
<img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/>
|
||||
<i v-else class="ti ti-badges"></i>
|
||||
|
|
@ -59,6 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
|
||||
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
|
||||
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
|
||||
<span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span>
|
||||
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
|
||||
<span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span>
|
||||
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
|
||||
|
|
@ -225,6 +227,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
--eventReactionHeart: var(--love);
|
||||
--eventReaction: #e99a0b;
|
||||
--eventAchievement: #cb9a11;
|
||||
--eventLogin: #007aff;
|
||||
--eventOther: #88a6b7;
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +349,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t_login {
|
||||
padding: 3px;
|
||||
background: var(--eventLogin);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-show="props.modelValue.length != 0" :class="$style.root">
|
||||
<Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)">
|
||||
<template #item="{element}">
|
||||
<div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
|
||||
<div
|
||||
:class="$style.file"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="showFileMenu(element, $event)"
|
||||
@keydown.space.enter="showFileMenu(element, $event)"
|
||||
@contextmenu.prevent="showFileMenu(element, $event)"
|
||||
>
|
||||
<MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
|
||||
<div v-if="element.isSensitive" :class="$style.sensitive">
|
||||
<i class="ti ti-eye-exclamation" style="margin: auto;"></i>
|
||||
|
|
@ -133,7 +140,7 @@ async function crop(file: Misskey.entities.DriveFile): Promise<void> {
|
|||
emit('replaceFile', file, newFile);
|
||||
}
|
||||
|
||||
function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
|
||||
function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void {
|
||||
if (menuShowing) return;
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
|
|
@ -199,6 +206,10 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void {
|
|||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: move;
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
|
|
|
|||
206
packages/frontend/src/components/MkSignin.input.vue
Normal file
206
packages/frontend/src/components/MkSignin.input.vue
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper" data-cy-signin-page-input>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.avatar">
|
||||
<i class="ti ti-user"></i>
|
||||
</div>
|
||||
|
||||
<!-- ログイン画面メッセージ -->
|
||||
<MkInfo v-if="message">
|
||||
{{ message }}
|
||||
</MkInfo>
|
||||
|
||||
<!-- 外部サーバーへの転送 -->
|
||||
<div v-if="openOnRemote" class="_gaps_m">
|
||||
<div class="_gaps_s">
|
||||
<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)">
|
||||
{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i>
|
||||
</MkButton>
|
||||
<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)">
|
||||
{{ i18n.ts.specifyServerHost }}
|
||||
</button>
|
||||
</div>
|
||||
<div :class="$style.orHr">
|
||||
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- username入力 -->
|
||||
<form class="_gaps_s" @submit.prevent="emit('usernameSubmitted', username)">
|
||||
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username>
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</MkInput>
|
||||
<MkButton type="submit" large primary rounded style="margin: 0 auto;" data-cy-signin-page-input-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</form>
|
||||
|
||||
<!-- パスワードレスログイン -->
|
||||
<div :class="$style.orHr">
|
||||
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<MkButton type="submit" style="margin: auto auto;" large rounded primary gradate @click="emit('passkeyClick', $event)">
|
||||
<i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { toUnicode } from 'punycode/';
|
||||
|
||||
import { query, extractDomain } from '@@/js/url.js';
|
||||
import { host as configHost } from '@@/js/config.js';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
message?: string,
|
||||
openOnRemote?: OpenOnRemoteOptions,
|
||||
}>(), {
|
||||
message: '',
|
||||
openOnRemote: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'usernameSubmitted', v: string): void;
|
||||
(ev: 'passkeyClick', v: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const host = toUnicode(configHost);
|
||||
|
||||
const username = ref('');
|
||||
|
||||
//#region Open on remote
|
||||
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
|
||||
switch (options.type) {
|
||||
case 'web':
|
||||
case 'lookup': {
|
||||
let _path: string;
|
||||
|
||||
if (options.type === 'lookup') {
|
||||
// TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼
|
||||
// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
|
||||
_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`;
|
||||
} else {
|
||||
_path = options.path;
|
||||
}
|
||||
|
||||
if (targetHost) {
|
||||
window.open(`https://${targetHost}${_path}`, '_blank', 'noopener');
|
||||
} else {
|
||||
window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'share': {
|
||||
const params = query(options.params);
|
||||
if (targetHost) {
|
||||
window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener');
|
||||
} else {
|
||||
window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> {
|
||||
const { canceled, result: hostTemp } = await os.inputText({
|
||||
title: i18n.ts.inputHostName,
|
||||
placeholder: 'misskey.example.com',
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
let targetHost: string | null = hostTemp;
|
||||
|
||||
// ドメイン部分だけを取り出す
|
||||
targetHost = extractDomain(targetHost ?? '');
|
||||
if (targetHost == null) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.invalidValue,
|
||||
text: i18n.ts.tryAgain,
|
||||
});
|
||||
return;
|
||||
}
|
||||
openRemote(options, targetHost);
|
||||
}
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 336px;
|
||||
|
||||
> .root {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin: 0 auto;
|
||||
background-color: color-mix(in srgb, var(--fg), transparent 85%);
|
||||
color: color-mix(in srgb, var(--fg), transparent 25%);
|
||||
text-align: center;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
font-size: 24px;
|
||||
line-height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.instanceManualSelectButton {
|
||||
display: block;
|
||||
text-align: center;
|
||||
opacity: .7;
|
||||
font-size: .8em;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.orHr {
|
||||
position: relative;
|
||||
margin: .4em auto;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.orMsg {
|
||||
position: absolute;
|
||||
top: -.6em;
|
||||
display: inline-block;
|
||||
padding: 0 1em;
|
||||
background: var(--panel);
|
||||
font-size: 0.8em;
|
||||
color: var(--fgOnPanel);
|
||||
margin: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
</style>
|
||||
92
packages/frontend/src/components/MkSignin.passkey.vue
Normal file
92
packages/frontend/src/components/MkSignin.passkey.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper">
|
||||
<div class="_gaps" :class="$style.root">
|
||||
<div class="_gaps_s">
|
||||
<div :class="$style.passkeyIcon">
|
||||
<i class="ti ti-fingerprint"></i>
|
||||
</div>
|
||||
<div :class="$style.passkeyDescription">{{ i18n.ts.useSecurityKey }}</div>
|
||||
</div>
|
||||
|
||||
<MkButton large primary rounded :disabled="queryingKey" style="margin: 0 auto;" @click="queryKey">{{ i18n.ts.retry }}</MkButton>
|
||||
|
||||
<MkButton v-if="isPerformingPasswordlessLogin !== true" transparent rounded :disabled="queryingKey" style="margin: 0 auto;" @click="emit('useTotp')">{{ i18n.ts.useTotp }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { get as webAuthnRequest } from '@github/webauthn-json/browser-ponyfill';
|
||||
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
|
||||
|
||||
const props = defineProps<{
|
||||
credentialRequest: CredentialRequestOptions;
|
||||
isPerformingPasswordlessLogin?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', credential: AuthenticationPublicKeyCredential): void;
|
||||
(ev: 'useTotp'): void;
|
||||
}>();
|
||||
|
||||
const queryingKey = ref(true);
|
||||
|
||||
async function queryKey() {
|
||||
queryingKey.value = true;
|
||||
await webAuthnRequest(props.credentialRequest)
|
||||
.catch(() => {
|
||||
return Promise.reject(null);
|
||||
})
|
||||
.then((credential) => {
|
||||
emit('done', credential);
|
||||
})
|
||||
.finally(() => {
|
||||
queryingKey.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryKey();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 336px;
|
||||
|
||||
> .root {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.passkeyIcon {
|
||||
margin: 0 auto;
|
||||
background-color: var(--accentedBg);
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
font-size: 24px;
|
||||
line-height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.passkeyDescription {
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
181
packages/frontend/src/components/MkSignin.password.vue
Normal file
181
packages/frontend/src/components/MkSignin.password.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper" data-cy-signin-page-password>
|
||||
<div class="_gaps" :class="$style.root">
|
||||
<div :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined }"></div>
|
||||
<div :class="$style.welcomeBackMessage">
|
||||
<I18n :src="i18n.ts.welcomeBackWithName" tag="span">
|
||||
<template #name><Mfm :text="user.name ?? user.username" :plain="true"/></template>
|
||||
</I18n>
|
||||
</div>
|
||||
|
||||
<!-- password入力 -->
|
||||
<form class="_gaps_s" @submit.prevent="onSubmit">
|
||||
<!-- ブラウザ オートコンプリート用 -->
|
||||
<input type="hidden" name="username" autocomplete="username" :value="user.username">
|
||||
|
||||
<MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required autofocus data-cy-signin-password>
|
||||
<template #prefix><i class="ti ti-lock"></i></template>
|
||||
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
||||
</MkInput>
|
||||
|
||||
<div v-if="needCaptcha">
|
||||
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
|
||||
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
|
||||
</div>
|
||||
|
||||
<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export type PwResponse = {
|
||||
password: string;
|
||||
captcha: {
|
||||
hCaptchaResponse: string | null;
|
||||
mCaptchaResponse: string | null;
|
||||
reCaptchaResponse: string | null;
|
||||
turnstileResponse: string | null;
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useTemplateRef, defineAsyncComponent } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
import { instance } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkCaptcha from '@/components/MkCaptcha.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
needCaptcha: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'passwordSubmitted', v: PwResponse): void;
|
||||
}>();
|
||||
|
||||
const password = ref('');
|
||||
|
||||
const hCaptcha = useTemplateRef('hcaptcha');
|
||||
const mCaptcha = useTemplateRef('mcaptcha');
|
||||
const reCaptcha = useTemplateRef('recaptcha');
|
||||
const turnstile = useTemplateRef('turnstile');
|
||||
|
||||
const hCaptchaResponse = ref<string | null>(null);
|
||||
const mCaptchaResponse = ref<string | null>(null);
|
||||
const reCaptchaResponse = ref<string | null>(null);
|
||||
const turnstileResponse = ref<string | null>(null);
|
||||
|
||||
const captchaFailed = computed((): boolean => {
|
||||
return (
|
||||
(instance.enableHcaptcha && !hCaptchaResponse.value) ||
|
||||
(instance.enableMcaptcha && !mCaptchaResponse.value) ||
|
||||
(instance.enableRecaptcha && !reCaptchaResponse.value) ||
|
||||
(instance.enableTurnstile && !turnstileResponse.value)
|
||||
);
|
||||
});
|
||||
|
||||
function resetPassword(): void {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
emit('passwordSubmitted', {
|
||||
password: password.value,
|
||||
captcha: {
|
||||
hCaptchaResponse: hCaptchaResponse.value,
|
||||
mCaptchaResponse: mCaptchaResponse.value,
|
||||
reCaptchaResponse: reCaptchaResponse.value,
|
||||
turnstileResponse: turnstileResponse.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resetCaptcha() {
|
||||
hCaptcha.value?.reset();
|
||||
mCaptcha.value?.reset();
|
||||
reCaptcha.value?.reset();
|
||||
turnstile.value?.reset();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetCaptcha,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 336px;
|
||||
|
||||
> .root {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin: 0 auto 0 auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #ddd;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.welcomeBackMessage {
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.instanceManualSelectButton {
|
||||
display: block;
|
||||
text-align: center;
|
||||
opacity: .7;
|
||||
font-size: .8em;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.orHr {
|
||||
position: relative;
|
||||
margin: .4em auto;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.orMsg {
|
||||
position: absolute;
|
||||
top: -.6em;
|
||||
display: inline-block;
|
||||
padding: 0 1em;
|
||||
background: var(--panel);
|
||||
font-size: 0.8em;
|
||||
color: var(--fgOnPanel);
|
||||
margin: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
</style>
|
||||
74
packages/frontend/src/components/MkSignin.totp.vue
Normal file
74
packages/frontend/src/components/MkSignin.totp.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper">
|
||||
<div class="_gaps" :class="$style.root">
|
||||
<div class="_gaps_s">
|
||||
<div :class="$style.totpIcon">
|
||||
<i class="ti ti-key"></i>
|
||||
</div>
|
||||
<div :class="$style.totpDescription">{{ i18n.ts['2fa'] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- totp入力 -->
|
||||
<form class="_gaps_s" @submit.prevent="emit('totpSubmitted', token)">
|
||||
<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required autofocus :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
|
||||
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
|
||||
<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
|
||||
<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
|
||||
</MkInput>
|
||||
|
||||
<MkButton type="submit" large primary rounded style="margin: 0 auto;">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'totpSubmitted', token: string): void;
|
||||
}>();
|
||||
|
||||
const token = ref('');
|
||||
const isBackupCode = ref(false);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 336px;
|
||||
|
||||
> .root {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.totpIcon {
|
||||
margin: 0 auto;
|
||||
background-color: var(--accentedBg);
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
font-size: 24px;
|
||||
line-height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.totpDescription {
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,408 +4,405 @@ 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>
|
||||
<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 :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 { 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 { 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 { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
|
||||
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { login } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
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('');
|
||||
import XInput from '@/components/MkSignin.input.vue';
|
||||
import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue';
|
||||
import XTotp from '@/components/MkSignin.totp.vue';
|
||||
import XPasskey from '@/components/MkSignin.passkey.vue';
|
||||
|
||||
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'login', v: 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: SigninWithPasskeyResponse) => {
|
||||
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(onSigninApiError);
|
||||
}
|
||||
}
|
||||
|
||||
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: SigninWithPasskeyResponse) => {
|
||||
function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void {
|
||||
waiting.value = true;
|
||||
|
||||
if (doingPasskeyFromInputPage.value) {
|
||||
misskeyApi('signin-with-passkey', {
|
||||
credential: credential.toJSON(),
|
||||
context: passkeyContext.value,
|
||||
}).then((res) => {
|
||||
if (res.signinResponse == null) {
|
||||
onSigninApiError();
|
||||
return;
|
||||
}
|
||||
emit('login', res.signinResponse);
|
||||
return onLogin(res.signinResponse);
|
||||
}).catch(onSigninApiError);
|
||||
} else if (userInfo.value != null) {
|
||||
tryLogin({
|
||||
username: userInfo.value.username,
|
||||
password: password.value,
|
||||
credential: credential.toJSON(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
signing.value = true;
|
||||
if (!totpLogin.value && user.value && user.value.twoFactorEnabled) {
|
||||
if (webAuthnSupported() && user.value.securityKeys) {
|
||||
misskeyApi('signin', {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
}).then(res => {
|
||||
totpLogin.value = true;
|
||||
signing.value = false;
|
||||
credentialRequest = parseRequestOptionsFromJSON({
|
||||
publicKey: res,
|
||||
});
|
||||
})
|
||||
.then(() => query2FaKey())
|
||||
.catch(loginFailed);
|
||||
} else {
|
||||
totpLogin.value = true;
|
||||
signing.value = false;
|
||||
function onUseTotp(): void {
|
||||
page.value = 'totp';
|
||||
}
|
||||
//#endregion
|
||||
|
||||
async function onUsernameSubmitted(username: string) {
|
||||
waiting.value = true;
|
||||
|
||||
userInfo.value = await misskeyApi('users/show', {
|
||||
username,
|
||||
}).catch(() => null);
|
||||
|
||||
await tryLogin({
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
async function onPasswordSubmitted(pw: PwResponse) {
|
||||
waiting.value = true;
|
||||
password.value = pw.password;
|
||||
|
||||
if (userInfo.value == null) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.noSuchUser,
|
||||
text: i18n.ts.signinFailed,
|
||||
});
|
||||
waiting.value = false;
|
||||
return;
|
||||
} else {
|
||||
await tryLogin({
|
||||
username: userInfo.value.username,
|
||||
password: pw.password,
|
||||
'hcaptcha-response': pw.captcha.hCaptchaResponse,
|
||||
'm-captcha-response': pw.captcha.mCaptchaResponse,
|
||||
'g-recaptcha-response': pw.captcha.reCaptchaResponse,
|
||||
'turnstile-response': pw.captcha.turnstileResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onTotpSubmitted(token: string) {
|
||||
waiting.value = true;
|
||||
|
||||
if (userInfo.value == null) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.noSuchUser,
|
||||
text: i18n.ts.signinFailed,
|
||||
});
|
||||
waiting.value = false;
|
||||
return;
|
||||
} else {
|
||||
await tryLogin({
|
||||
username: userInfo.value.username,
|
||||
password: password.value,
|
||||
token,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLogin(req: Partial<Misskey.entities.SigninRequest>): Promise<Misskey.entities.SigninResponse> {
|
||||
const _req = {
|
||||
username: req.username ?? userInfo.value?.username,
|
||||
...req,
|
||||
};
|
||||
|
||||
function assertIsSigninRequest(x: Partial<Misskey.entities.SigninRequest>): x is Misskey.entities.SigninRequest {
|
||||
return x.username != null;
|
||||
}
|
||||
|
||||
if (!assertIsSigninRequest(_req)) {
|
||||
throw new Error('Invalid request');
|
||||
}
|
||||
|
||||
return await misskeyApi('signin', _req).then(async (res) => {
|
||||
emit('login', res);
|
||||
await onLoginSucceeded(res);
|
||||
return res;
|
||||
}).catch((err) => {
|
||||
onSigninApiError(err);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
async function onLoginSucceeded(res: Misskey.entities.SigninResponse) {
|
||||
if (props.autoSet) {
|
||||
await login(res.i);
|
||||
}
|
||||
}
|
||||
|
||||
function onSigninApiError(err?: any): void {
|
||||
const id = err?.id ?? null;
|
||||
|
||||
if (typeof err === 'object' && 'next' in err) {
|
||||
switch (err.next) {
|
||||
case 'captcha': {
|
||||
needCaptcha.value = true;
|
||||
page.value = 'password';
|
||||
break;
|
||||
}
|
||||
case 'password': {
|
||||
needCaptcha.value = false;
|
||||
page.value = 'password';
|
||||
break;
|
||||
}
|
||||
case 'totp': {
|
||||
page.value = 'totp';
|
||||
break;
|
||||
}
|
||||
case 'passkey': {
|
||||
if (webAuthnSupported() && 'authRequest' in err) {
|
||||
credentialRequest.value = parseRequestOptionsFromJSON({
|
||||
publicKey: err.authRequest,
|
||||
});
|
||||
page.value = 'passkey';
|
||||
} else {
|
||||
page.value = 'totp';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
misskeyApi('signin', {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
token: user.value?.twoFactorEnabled ? token.value : undefined,
|
||||
}).then(res => {
|
||||
emit('login', res);
|
||||
onLogin(res);
|
||||
}).catch(loginFailed);
|
||||
}
|
||||
}
|
||||
|
||||
function loginFailed(err: any): void {
|
||||
switch (err.id) {
|
||||
case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.noSuchUser,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.incorrectPassword,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'e03a5f46-d309-4865-9b69-56282d94e1eb': {
|
||||
showSuspendedDialog();
|
||||
break;
|
||||
}
|
||||
case '22d05606-fbcf-421a-a2db-b32610dcfd1b': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.rateLimitExceeded,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.unknownWebAuthnKey,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.passkeyVerificationFailed,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case '2d84773e-f7b7-4d0b-8f72-bb69b584c912': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.passkeyVerificationSucceededButPasswordlessLoginDisabled,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: JSON.stringify(err),
|
||||
});
|
||||
switch (id) {
|
||||
case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.noSuchUser,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.incorrectPassword,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'e03a5f46-d309-4865-9b69-56282d94e1eb': {
|
||||
showSuspendedDialog();
|
||||
break;
|
||||
}
|
||||
case '22d05606-fbcf-421a-a2db-b32610dcfd1b': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.rateLimitExceeded,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.incorrectTotp,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.unknownWebAuthnKey,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case '93b86c4b-72f9-40eb-9815-798928603d1e': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.passkeyVerificationFailed,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.passkeyVerificationFailed,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case '2d84773e-f7b7-4d0b-8f72-bb69b584c912': {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: i18n.ts.passkeyVerificationSucceededButPasswordlessLoginDisabled,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.loginFailed,
|
||||
text: JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totpLogin.value = false;
|
||||
signing.value = false;
|
||||
}
|
||||
|
||||
function resetPassword(): void {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
|
||||
closed: () => dispose(),
|
||||
if (doingPasskeyFromInputPage.value === true) {
|
||||
doingPasskeyFromInputPage.value = false;
|
||||
page.value = 'input';
|
||||
password.value = '';
|
||||
}
|
||||
passwordPageEl.value?.resetCaptcha();
|
||||
nextTick(() => {
|
||||
waiting.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
|
||||
switch (options.type) {
|
||||
case 'web':
|
||||
case 'lookup': {
|
||||
let _path: string;
|
||||
|
||||
if (options.type === 'lookup') {
|
||||
// TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼
|
||||
// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
|
||||
_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`;
|
||||
} else {
|
||||
_path = options.path;
|
||||
}
|
||||
|
||||
if (targetHost) {
|
||||
window.open(`https://${targetHost}${_path}`, '_blank', 'noopener');
|
||||
} else {
|
||||
window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'share': {
|
||||
const params = query(options.params);
|
||||
if (targetHost) {
|
||||
window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener');
|
||||
} else {
|
||||
window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> {
|
||||
const { canceled, result: hostTemp } = await os.inputText({
|
||||
title: i18n.ts.inputHostName,
|
||||
placeholder: 'misskey.example.com',
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
let targetHost: string | null = hostTemp;
|
||||
|
||||
// ドメイン部分だけを取り出す
|
||||
targetHost = extractDomain(targetHost);
|
||||
if (targetHost == null) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.invalidValue,
|
||||
text: i18n.ts.tryAgain,
|
||||
});
|
||||
return;
|
||||
}
|
||||
openRemote(options, targetHost);
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
password.value = '';
|
||||
needCaptcha.value = false;
|
||||
userInfo.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.avatar {
|
||||
margin: 0 auto 0 auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #ddd;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border-radius: 100%;
|
||||
.transition_enterActive,
|
||||
.transition_leaveActive {
|
||||
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
|
||||
}
|
||||
.transition_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
.transition_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
|
||||
.instanceManualSelectButton {
|
||||
display: block;
|
||||
text-align: center;
|
||||
opacity: .7;
|
||||
font-size: .8em;
|
||||
.signinRoot {
|
||||
overflow-x: hidden;
|
||||
overflow-x: clip;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.orHr {
|
||||
position: relative;
|
||||
margin: .4em auto;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.orMsg {
|
||||
.waitingRoot {
|
||||
position: absolute;
|
||||
top: -.6em;
|
||||
display: inline-block;
|
||||
padding: 0 1em;
|
||||
background: var(--panel);
|
||||
font-size: 0.8em;
|
||||
color: var(--fgOnPanel);
|
||||
margin: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: color-mix(in srgb, var(--panel), transparent 50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,26 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="400"
|
||||
:height="450"
|
||||
@close="onClose"
|
||||
<MkModal
|
||||
ref="modal"
|
||||
:preferType="'dialog'"
|
||||
@click="onClose"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.login }}</template>
|
||||
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/>
|
||||
</MkSpacer>
|
||||
</MkModalWindow>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.headerText"><i class="ti ti-login-2"></i> {{ i18n.ts.login }}</div>
|
||||
<button :class="$style.closeButton" class="_button" @click="onClose"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import MkSignin from '@/components/MkSignin.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
|
|
@ -42,15 +45,62 @@ const emit = defineEmits<{
|
|||
(ev: 'cancelled'): void;
|
||||
}>();
|
||||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
function onClose() {
|
||||
emit('cancelled');
|
||||
if (dialog.value) dialog.value.close();
|
||||
if (modal.value) modal.value.close();
|
||||
}
|
||||
|
||||
function onLogin(res) {
|
||||
emit('done', res);
|
||||
if (dialog.value) dialog.value.close();
|
||||
if (modal.value) modal.value.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
overflow: auto;
|
||||
margin: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 100%;
|
||||
max-height: 450px;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
background: var(--acrylicBg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.headerText {
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
margin-left: auto;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -81,10 +81,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref, computed } from 'vue';
|
||||
import { toUnicode } from 'punycode/';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkInput from './MkInput.vue';
|
||||
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
|
||||
import * as config from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { login } from '@/account.js';
|
||||
|
|
@ -105,6 +105,7 @@ const emit = defineEmits<{
|
|||
const host = toUnicode(config.host);
|
||||
|
||||
const hcaptcha = ref<Captcha | undefined>();
|
||||
const mcaptcha = ref<Captcha | undefined>();
|
||||
const recaptcha = ref<Captcha | undefined>();
|
||||
const turnstile = ref<Captcha | undefined>();
|
||||
|
||||
|
|
@ -281,6 +282,7 @@ async function onSubmit(): Promise<void> {
|
|||
} catch {
|
||||
submitting.value = false;
|
||||
hcaptcha.value?.reset?.();
|
||||
mcaptcha.value?.reset?.();
|
||||
recaptcha.value?.reset?.();
|
||||
turnstile.value?.reset?.();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { VNode, h, SetupContext, provide } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import MkUrl from '@/components/global/MkUrl.vue';
|
||||
import MkTime from '@/components/global/MkTime.vue';
|
||||
import MkLink from '@/components/MkLink.vue';
|
||||
|
|
@ -17,7 +18,6 @@ import MkCodeInline from '@/components/MkCodeInline.vue';
|
|||
import MkGoogle from '@/components/MkGoogle.vue';
|
||||
import MkSparkle from '@/components/MkSparkle.vue';
|
||||
import MkA, { MkABehavior } from '@/components/global/MkA.vue';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
function safeParseFloat(str: unknown): number | null {
|
||||
|
|
@ -57,7 +57,8 @@ type MfmEvents = {
|
|||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) {
|
||||
provide('linkNavigationBehavior', props.linkNavigationBehavior);
|
||||
// こうしたいところだけど functional component 内では provide は使えない
|
||||
//provide('linkNavigationBehavior', props.linkNavigationBehavior);
|
||||
|
||||
const isNote = props.isNote ?? true;
|
||||
const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
|
||||
|
|
@ -350,6 +351,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
key: Math.random(),
|
||||
url: token.props.url,
|
||||
rel: 'nofollow noopener',
|
||||
navigationBehavior: props.linkNavigationBehavior,
|
||||
})];
|
||||
}
|
||||
|
||||
|
|
@ -358,6 +360,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
key: Math.random(),
|
||||
url: token.props.url,
|
||||
rel: 'nofollow noopener',
|
||||
navigationBehavior: props.linkNavigationBehavior,
|
||||
}, genEl(token.children, scale, true))];
|
||||
}
|
||||
|
||||
|
|
@ -366,6 +369,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
key: Math.random(),
|
||||
host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host,
|
||||
username: token.props.username,
|
||||
navigationBehavior: props.linkNavigationBehavior,
|
||||
})];
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +378,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
key: Math.random(),
|
||||
to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
|
||||
style: 'color:var(--hashtag);',
|
||||
behavior: props.linkNavigationBehavior,
|
||||
}, `#${token.props.hashtag}`)];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import MkLoadingPage from '@/pages/_loading_.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
router?: IRouter;
|
||||
nested?: boolean;
|
||||
}>();
|
||||
|
||||
const router = props.router ?? inject('router');
|
||||
|
|
@ -39,6 +40,8 @@ const currentDepth = inject('routerCurrentDepth', 0);
|
|||
provide('routerCurrentDepth', currentDepth + 1);
|
||||
|
||||
function resolveNested(current: Resolved, d = 0): Resolved | null {
|
||||
if (!props.nested) return current;
|
||||
|
||||
if (d === currentDepth) {
|
||||
return current;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -44,8 +44,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
-->
|
||||
|
||||
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
|
||||
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
|
||||
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination">
|
||||
<div class="_gaps">
|
||||
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkSpacer>
|
||||
</div>
|
||||
<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
|
||||
<RouterView/>
|
||||
<RouterView nested/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
|
||||
<MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--margin);">
|
||||
<div class="_gaps_s">
|
||||
<XModLog v-for="item in items" :key="item.id" :log="item"/>
|
||||
</div>
|
||||
<MkDateSeparatedList v-slot="{ item }" :items="items" :noGap="false" style="--margin: 8px;">
|
||||
<XModLog :key="item.id" :log="item"/>
|
||||
</MkDateSeparatedList>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
|
|
@ -39,6 +39,7 @@ import MkInput from '@/components/MkInput.vue';
|
|||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
|
||||
const logs = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<MkFolder>
|
||||
<template #label>{{ entity.name || entity.url }}</template>
|
||||
<template v-if="entity.name != null && entity.name != ''" #caption>{{ entity.url }}</template>
|
||||
<template #icon>
|
||||
<i v-if="!entity.isActive" class="ti ti-player-pause"/>
|
||||
<i v-else-if="entity.latestStatus === null" class="ti ti-circle"/>
|
||||
|
|
|
|||
|
|
@ -14,30 +14,39 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
<template #default="{items}">
|
||||
<div class="_gaps">
|
||||
<div v-for="token in items" :key="token.id" class="_panel" :class="$style.app">
|
||||
<img v-if="token.iconUrl" :class="$style.appIcon" :src="token.iconUrl" alt=""/>
|
||||
<div :class="$style.appBody">
|
||||
<div :class="$style.appName">{{ token.name }}</div>
|
||||
<div>{{ token.description }}</div>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.installedDate }}</template>
|
||||
<template #value><MkTime :time="token.createdAt"/></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.lastUsedDate }}</template>
|
||||
<template #value><MkTime :time="token.lastUsedAt"/></template>
|
||||
</MkKeyValue>
|
||||
<details>
|
||||
<summary>{{ i18n.ts.details }}</summary>
|
||||
<MkFolder v-for="token in items" :key="token.id" :defaultOpen="true">
|
||||
<template #icon>
|
||||
<img v-if="token.iconUrl" :class="$style.appIcon" :src="token.iconUrl" alt=""/>
|
||||
<i v-else class="ti ti-plug"/>
|
||||
</template>
|
||||
<template #label>{{ token.name }}</template>
|
||||
<template #caption>{{ token.description }}</template>
|
||||
<template #suffix><MkTime :time="token.lastUsedAt"/></template>
|
||||
<template #footer>
|
||||
<MkButton danger @click="revoke(token)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<div v-if="token.description">{{ token.description }}</div>
|
||||
<div>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.installedDate }}</template>
|
||||
<template #value><MkTime :time="token.createdAt" :mode="'detail'"/></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.lastUsedDate }}</template>
|
||||
<template #value><MkTime :time="token.lastUsedAt" :mode="'detail'"/></template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts.permission }}</template>
|
||||
<template #suffix>{{ Object.keys(token.permission).length === 0 ? i18n.ts.none : Object.keys(token.permission).length }}</template>
|
||||
<ul>
|
||||
<li v-for="p in token.permission" :key="p">{{ i18n.ts._permissions[p] }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div>
|
||||
<MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</template>
|
||||
</FormPagination>
|
||||
|
|
@ -52,6 +61,7 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const list = ref<InstanceType<typeof FormPagination>>();
|
||||
|
|
@ -82,26 +92,9 @@ definePageMetadata(() => ({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.app {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.appIcon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px 0 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.appBody {
|
||||
width: calc(100% - 62px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-weight: bold;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
|
||||
<div class="bkzroven" style="container-type: inline-size;">
|
||||
<RouterView/>
|
||||
<RouterView nested/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,14 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkFolder>
|
||||
<template #icon><i class="ti ti-list"></i></template>
|
||||
<template #label>{{ i18n.ts._profile.metadataEdit }}</template>
|
||||
|
||||
<div :class="$style.metadataRoot">
|
||||
<div :class="$style.metadataMargin">
|
||||
<MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
<MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" inline danger style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
<MkButton v-else inline style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton>
|
||||
<MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton :disabled="fields.length >= 16" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
<MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" danger @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
<MkButton v-else @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.metadataRoot" class="_gaps_s">
|
||||
<MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo>
|
||||
|
||||
<Sortable
|
||||
v-model="fields"
|
||||
|
|
@ -65,24 +68,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@end="e => e.item.classList.remove('active')"
|
||||
>
|
||||
<template #item="{element, index}">
|
||||
<div :class="$style.fieldDragItem">
|
||||
<div v-panel :class="$style.fieldDragItem">
|
||||
<button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button>
|
||||
<button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button>
|
||||
<div :class="$style.dragItemForm">
|
||||
<FormSplit :minWidth="200">
|
||||
<MkInput v-model="element.name" small>
|
||||
<template #label>{{ i18n.ts._profile.metadataLabel }}</template>
|
||||
<MkInput v-model="element.name" small :placeholder="i18n.ts._profile.metadataLabel">
|
||||
</MkInput>
|
||||
<MkInput v-model="element.value" small>
|
||||
<template #label>{{ i18n.ts._profile.metadataContent }}</template>
|
||||
<MkInput v-model="element.value" small :placeholder="i18n.ts._profile.metadataContent">
|
||||
</MkInput>
|
||||
</FormSplit>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Sortable>
|
||||
|
||||
<MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
|
||||
|
|
@ -310,19 +309,11 @@ definePageMetadata(() => ({
|
|||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.metadataMargin {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.fieldDragItem {
|
||||
display: flex;
|
||||
padding-bottom: .75em;
|
||||
padding: 10px;
|
||||
align-items: flex-end;
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
border-radius: 6px;
|
||||
|
||||
/* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */
|
||||
@container (max-width: 452px) {
|
||||
|
|
|
|||
|
|
@ -48,9 +48,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-if="user.followedMessage != null" class="followedMessage">
|
||||
<div style="border: solid 1px var(--love); border-radius: 6px; background: color-mix(in srgb, var(--love), transparent 90%); padding: 6px 8px;">
|
||||
<Mfm :text="user.followedMessage" :author="user"/>
|
||||
</div>
|
||||
<MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin shadow>
|
||||
<div class="messageHeader">{{ i18n.ts.messageToFollower }}</div>
|
||||
<div><Mfm :text="user.followedMessage" :author="user"/></div>
|
||||
</MkFukidashi>
|
||||
</div>
|
||||
<div v-if="user.roles.length > 0" class="roles">
|
||||
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }">
|
||||
|
|
@ -161,6 +162,7 @@ import * as Misskey from 'misskey-js';
|
|||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
import MkAccountMoved from '@/components/MkAccountMoved.vue';
|
||||
import MkFukidashi from '@/components/MkFukidashi.vue';
|
||||
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkOmit from '@/components/MkOmit.vue';
|
||||
|
|
@ -467,7 +469,18 @@ onUnmounted(() => {
|
|||
|
||||
> .followedMessage {
|
||||
padding: 24px 24px 0 154px;
|
||||
font-size: 0.9em;
|
||||
|
||||
> .fukidashi {
|
||||
display: block;
|
||||
--fukidashi-bg: color-mix(in srgb, var(--love), var(--panel) 85%);
|
||||
--fukidashi-radius: 16px;
|
||||
font-size: 0.9em;
|
||||
|
||||
.messageHeader {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .roles {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div class="_gaps_m" style="padding: 32px;">
|
||||
<div>{{ i18n.ts.intro }}</div>
|
||||
<MkInput v-model="setupPassword" type="password" data-cy-admin-initial-password>
|
||||
<template #label>{{ i18n.ts.initialPasswordForSetup }} <div v-tooltip:dialog="i18n.ts.initialPasswordForSetupDescription" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
|
||||
<template #prefix><i class="ti ti-lock"></i></template>
|
||||
</MkInput>
|
||||
<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username>
|
||||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
|
|
@ -36,9 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { host, version } from '@@/js/config.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import { host, version } from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { login } from '@/account.js';
|
||||
|
|
@ -47,6 +51,7 @@ import MkAnimBg from '@/components/MkAnimBg.vue';
|
|||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const setupPassword = ref('');
|
||||
const submitting = ref(false);
|
||||
|
||||
function submit() {
|
||||
|
|
@ -56,14 +61,27 @@ function submit() {
|
|||
misskeyApi('admin/accounts/create', {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
setupPassword: setupPassword.value === '' ? null : setupPassword.value,
|
||||
}).then(res => {
|
||||
return login(res.token);
|
||||
}).catch(() => {
|
||||
}).catch((err) => {
|
||||
submitting.value = false;
|
||||
|
||||
let title = i18n.ts.somethingHappened;
|
||||
let text = err.message + '\n' + err.id;
|
||||
|
||||
if (err.code === 'ACCESS_DENIED') {
|
||||
title = i18n.ts.permissionDeniedError;
|
||||
text = i18n.ts.operationForbidden;
|
||||
} else if (err.code === 'INCORRECT_INITIAL_PASSWORD') {
|
||||
title = i18n.ts.permissionDeniedError;
|
||||
text = i18n.ts.incorrectPassword;
|
||||
}
|
||||
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
title,
|
||||
text,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -74,8 +92,8 @@ function submit() {
|
|||
min-height: 100svh;
|
||||
padding: 32px 32px 64px 32px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.form {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ export function getConfig(): UserConfig {
|
|||
}
|
||||
},
|
||||
},
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
define: {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
```ts
|
||||
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "components" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
|
|
@ -1162,7 +1164,19 @@ export type Endpoints = Overwrite<Endpoints_2, {
|
|||
};
|
||||
'signin-with-passkey': {
|
||||
req: SigninWithPasskeyRequest;
|
||||
res: SigninWithPasskeyResponse;
|
||||
res: {
|
||||
$switch: {
|
||||
$cases: [
|
||||
[
|
||||
{
|
||||
context: string;
|
||||
},
|
||||
SigninWithPasskeyResponse
|
||||
]
|
||||
];
|
||||
$default: SigninWithPasskeyInitResponse;
|
||||
};
|
||||
};
|
||||
};
|
||||
'admin/roles/create': {
|
||||
req: Overwrite<AdminRolesCreateRequest, {
|
||||
|
|
@ -1196,6 +1210,7 @@ declare namespace entities {
|
|||
SignupPendingResponse,
|
||||
SigninRequest,
|
||||
SigninWithPasskeyRequest,
|
||||
SigninWithPasskeyInitResponse,
|
||||
SigninWithPasskeyResponse,
|
||||
SigninResponse,
|
||||
PartialRolePolicyOverride,
|
||||
|
|
@ -3033,8 +3048,13 @@ type Signin = components['schemas']['Signin'];
|
|||
// @public (undocumented)
|
||||
type SigninRequest = {
|
||||
username: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string | null;
|
||||
'g-recaptcha-response'?: string | null;
|
||||
'turnstile-response'?: string | null;
|
||||
'm-captcha-response'?: string | null;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
|
@ -3043,17 +3063,21 @@ type SigninResponse = {
|
|||
i: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
type SigninWithPasskeyInitResponse = {
|
||||
option: PublicKeyCredentialRequestOptionsJSON;
|
||||
context: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
type SigninWithPasskeyRequest = {
|
||||
credential?: object;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
context?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
type SigninWithPasskeyResponse = {
|
||||
option?: object;
|
||||
context?: string;
|
||||
signinResponse?: SigninResponse;
|
||||
signinResponse: SigninResponse;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
|
@ -3077,6 +3101,7 @@ type SignupRequest = {
|
|||
'hcaptcha-response'?: string | null;
|
||||
'g-recaptcha-response'?: string | null;
|
||||
'turnstile-response'?: string | null;
|
||||
'm-captcha-response'?: string | null;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
|
@ -3354,7 +3379,7 @@ type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody']['
|
|||
|
||||
// Warnings were encountered during analysis:
|
||||
//
|
||||
// src/entities.ts:49:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||
// src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||
// src/streaming.types.ts:220:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts
|
||||
// src/streaming.types.ts:230:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2024.9.0",
|
||||
"version": "2024.10.0-beta.4",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
|
@ -57,6 +57,7 @@
|
|||
"built"
|
||||
],
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "10.0.0",
|
||||
"eventemitter3": "5.0.1",
|
||||
"reconnecting-websocket": "4.4.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
PartialRolePolicyOverride,
|
||||
SigninRequest,
|
||||
SigninResponse,
|
||||
SigninWithPasskeyInitResponse,
|
||||
SigninWithPasskeyRequest,
|
||||
SigninWithPasskeyResponse,
|
||||
SignupPendingRequest,
|
||||
|
|
@ -86,8 +87,20 @@ export type Endpoints = Overwrite<
|
|||
},
|
||||
'signin-with-passkey': {
|
||||
req: SigninWithPasskeyRequest;
|
||||
res: SigninWithPasskeyResponse;
|
||||
}
|
||||
res: {
|
||||
$switch: {
|
||||
$cases: [
|
||||
[
|
||||
{
|
||||
context: string;
|
||||
},
|
||||
SigninWithPasskeyResponse,
|
||||
],
|
||||
];
|
||||
$default: SigninWithPasskeyInitResponse;
|
||||
},
|
||||
},
|
||||
},
|
||||
'admin/roles/create': {
|
||||
req: Overwrite<AdminRolesCreateRequest, { policies: PartialRolePolicyOverride }>;
|
||||
res: AdminRolesCreateResponse;
|
||||
|
|
|
|||
|
|
@ -3791,16 +3791,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;
|
||||
|
|
@ -3981,6 +3978,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?: {
|
||||
|
|
@ -4297,7 +4300,14 @@ export type components = {
|
|||
exportedEntity: 'antenna' | 'blocking' | 'clip' | 'customEmoji' | 'favorite' | 'following' | 'muting' | 'note' | 'userList';
|
||||
/** Format: id */
|
||||
fileId: string;
|
||||
}) | ({
|
||||
}) | {
|
||||
/** Format: id */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** @enum {string} */
|
||||
type: 'login';
|
||||
} | ({
|
||||
/** Format: id */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
|
|
@ -5189,6 +5199,8 @@ export type operations = {
|
|||
urlPreviewRequireContentLength: boolean;
|
||||
urlPreviewUserAgent: string | null;
|
||||
urlPreviewSummaryProxyUrl: string | null;
|
||||
federation: string;
|
||||
federationHosts: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -5614,6 +5626,7 @@ export type operations = {
|
|||
'application/json': {
|
||||
username: string;
|
||||
password: string;
|
||||
setupPassword?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -9440,6 +9453,9 @@ export type operations = {
|
|||
urlPreviewRequireContentLength?: boolean;
|
||||
urlPreviewUserAgent?: string | null;
|
||||
urlPreviewSummaryProxyUrl?: string | null;
|
||||
/** @enum {string} */
|
||||
federation?: 'all' | 'none' | 'specified';
|
||||
federationHosts?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -18557,8 +18573,8 @@ export type operations = {
|
|||
untilId?: string;
|
||||
/** @default true */
|
||||
markAsRead?: boolean;
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -18625,8 +18641,8 @@ export type operations = {
|
|||
untilId?: string;
|
||||
/** @default true */
|
||||
markAsRead?: boolean;
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
User,
|
||||
UserDetailedNotMe,
|
||||
} from './autogen/models.js';
|
||||
import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
|
||||
|
||||
export * from './autogen/entities.js';
|
||||
export * from './autogen/models.js';
|
||||
|
|
@ -250,6 +251,7 @@ export type SignupRequest = {
|
|||
'hcaptcha-response'?: string | null;
|
||||
'g-recaptcha-response'?: string | null;
|
||||
'turnstile-response'?: string | null;
|
||||
'm-captcha-response'?: string | null;
|
||||
}
|
||||
|
||||
export type SignupResponse = MeDetailed & {
|
||||
|
|
@ -267,19 +269,27 @@ export type SignupPendingResponse = {
|
|||
|
||||
export type SigninRequest = {
|
||||
username: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string | null;
|
||||
'g-recaptcha-response'?: string | null;
|
||||
'turnstile-response'?: string | null;
|
||||
'm-captcha-response'?: string | null;
|
||||
};
|
||||
|
||||
export type SigninWithPasskeyRequest = {
|
||||
credential?: object;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
context?: string;
|
||||
};
|
||||
|
||||
export type SigninWithPasskeyInitResponse = {
|
||||
option: PublicKeyCredentialRequestOptionsJSON;
|
||||
context: string;
|
||||
};
|
||||
|
||||
export type SigninWithPasskeyResponse = {
|
||||
option?: object;
|
||||
context?: string;
|
||||
signinResponse?: SigninResponse;
|
||||
signinResponse: SigninResponse;
|
||||
};
|
||||
|
||||
export type SigninResponse = {
|
||||
|
|
|
|||
|
|
@ -210,6 +210,12 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
|
|||
tag: `achievement:${data.body.achievement}`,
|
||||
}];
|
||||
|
||||
case 'login':
|
||||
return [i18n.ts._notification.login, {
|
||||
badge: iconUrl('login-2'),
|
||||
data,
|
||||
}];
|
||||
|
||||
case 'exportCompleted': {
|
||||
const entityName = {
|
||||
antenna: i18n.ts.antennas,
|
||||
|
|
|
|||
|
|
@ -50,4 +50,5 @@ export type BadgeNames =
|
|||
| 'quote'
|
||||
| 'repeat'
|
||||
| 'user-plus'
|
||||
| 'users';
|
||||
| 'users'
|
||||
| 'login-2';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue