feat(sign-in): メールアドレスログインを実装 (MisskeyIO#836)
Co-authored-by: まっちゃてぃー。 <56515516+mattyatea@users.noreply.github.com>
This commit is contained in:
parent
3ecc340168
commit
58513c1b81
14 changed files with 236 additions and 22 deletions
8
locales/index.d.ts
vendored
8
locales/index.d.ts
vendored
|
@ -5318,6 +5318,14 @@ export interface Locale extends ILocale {
|
|||
* 選択した項目のみ許可
|
||||
*/
|
||||
"consentSelected": string;
|
||||
/**
|
||||
* メールアドレスでログイン
|
||||
*/
|
||||
"emailAddressLogin": string;
|
||||
/**
|
||||
* ユーザー名でログイン
|
||||
*/
|
||||
"usernameLogin": string;
|
||||
"_bubbleGame": {
|
||||
/**
|
||||
* 遊び方
|
||||
|
|
|
@ -1323,6 +1323,8 @@ pleaseConsentToTracking: "{host}は[プライバシーポリシー]({privacyPoli
|
|||
consentEssential: "必須項目のみ許可"
|
||||
consentAll: "全て許可"
|
||||
consentSelected: "選択した項目のみ許可"
|
||||
emailAddressLogin: "メールアドレスでログイン"
|
||||
usernameLogin: "ユーザー名でログイン"
|
||||
|
||||
_bubbleGame:
|
||||
howToPlay: "遊び方"
|
||||
|
|
|
@ -382,6 +382,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
|
|||
import * as ep___users_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_get_security_info from './endpoints/users/get-security-info.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||
|
@ -751,6 +752,7 @@ const $users_following: Provider = { provide: 'ep:users/following', useClass: ep
|
|||
const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default };
|
||||
const $users_getFollowingBirthdayUsers: Provider = { provide: 'ep:users/get-following-birthday-users', useClass: ep___users_getFollowingBirthdayUsers.default };
|
||||
const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default };
|
||||
const $users_getSecurityInfo: Provider = { provide: 'ep:users/get-security-info', useClass: ep___users_get_security_info.default };
|
||||
const $users_getSkebStatus: Provider = { provide: 'ep:users/get-skeb-status', useClass: ep___users_getSkebStatus.default };
|
||||
const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default };
|
||||
const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default };
|
||||
|
@ -1149,6 +1151,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$users_gallery_posts,
|
||||
$users_getFollowingBirthdayUsers,
|
||||
$users_getFrequentlyRepliedUsers,
|
||||
$users_getSecurityInfo,
|
||||
$users_getSkebStatus,
|
||||
$users_featuredNotes,
|
||||
$users_lists_create,
|
||||
|
@ -1539,6 +1542,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$users_gallery_posts,
|
||||
$users_getFollowingBirthdayUsers,
|
||||
$users_getFrequentlyRepliedUsers,
|
||||
$users_getSecurityInfo,
|
||||
$users_getSkebStatus,
|
||||
$users_featuredNotes,
|
||||
$users_lists_create,
|
||||
|
|
|
@ -3,11 +3,13 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
MiUserProfile,
|
||||
SigninsRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
|
@ -27,7 +29,6 @@ import { RateLimiterService } from './RateLimiterService.js';
|
|||
import { SigninService } from './SigninService.js';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/server';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class SigninApiService {
|
||||
|
@ -122,22 +123,34 @@ export class SigninApiService {
|
|||
}
|
||||
|
||||
// Fetch user
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
}) as MiLocalUser;
|
||||
const profile = await this.userProfilesRepository.findOne({
|
||||
relations: ['user'],
|
||||
where: username.includes('@') ? {
|
||||
email: username,
|
||||
emailVerified: true,
|
||||
user: {
|
||||
host: IsNull(),
|
||||
}
|
||||
} : {
|
||||
user: {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
}
|
||||
}
|
||||
});
|
||||
const user = (profile?.user as MiLocalUser) ?? null;
|
||||
|
||||
if (user == null) {
|
||||
if (!user || !profile) {
|
||||
logger.error('No such user.');
|
||||
return error(404, {
|
||||
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||
return error(403, {
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isDeleted && user.isSuspended) {
|
||||
logger.error('No such user. (logical deletion)');
|
||||
return error(404, {
|
||||
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||
return error(403, {
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -148,8 +161,6 @@ export class SigninApiService {
|
|||
});
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(password, profile.password!);
|
||||
|
||||
|
|
|
@ -381,6 +381,7 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js';
|
|||
import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js';
|
||||
import * as ep___users_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_get_security_info from './endpoints/users/get-security-info.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||
|
@ -773,6 +774,7 @@ const eps = [
|
|||
['users/search-by-username-and-host', ep___users_searchByUsernameAndHost],
|
||||
['users/search', ep___users_search],
|
||||
['users/show', ep___users_show],
|
||||
['users/get-security-info', ep___users_get_security_info],
|
||||
['users/stats', ep___users_stats],
|
||||
['users/achievements', ep___users_achievements],
|
||||
['users/update-memo', ep___users_updateMemo],
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import ms from 'ms';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 30,
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
twoFactorEnabled: { type: 'boolean' },
|
||||
usePasswordLessLogin: { type: 'boolean' },
|
||||
securityKeys: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
required: ['email', 'password'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const profile = await this.userProfilesRepository.findOneBy({
|
||||
email: ps.email,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
const passwordMatched = await bcrypt.compare(ps.password, profile?.password ?? '');
|
||||
if (!profile || !passwordMatched) {
|
||||
return {
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
securityKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
twoFactorEnabled: profile.twoFactorEnabled,
|
||||
usePasswordLessLogin: profile.usePasswordLessLogin,
|
||||
securityKeys: profile.twoFactorEnabled
|
||||
? await this.userSecurityKeysRepository.countBy({ userId: profile.userId }).then(result => result >= 1)
|
||||
: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { set as gtagSet, time as gtagTime } from 'vue-gtag';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
@ -14,7 +15,6 @@ import { apiUrl } from '@/config.js';
|
|||
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
|
||||
import { generateClientTransactionId, misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
|
||||
import { set as gtagSet, time as gtagTime } from 'vue-gtag';
|
||||
import { instance } from '@/instance.js';
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
|
|
@ -6,24 +6,32 @@ 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>
|
||||
<div v-show="withAvatar && !loginWithEmailAddress" :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="!totpLogin" class="normal-signin _gaps_m">
|
||||
<MkInput v-model="username" :debounce="true" :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 v-model="username" :debounce="true" :placeholder="loginWithEmailAddress ? i18n.ts.emailAddress : i18n.ts.username" type="text" :pattern="loginWithEmailAddress ? '^[a-zA-Z0-9_@.]+$' : '^[a-zA-Z0-9_]+$'" :spellcheck="false" :autocomplete="loginWithEmailAddress ? 'email webauthn' : 'username webauthn'" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
|
||||
<template #prefix>
|
||||
<i v-if="loginWithEmailAddress" class="ti ti-mail"></i>
|
||||
<span v-else>@</span>
|
||||
</template>
|
||||
<template v-if="!loginWithEmailAddress" #suffix>@{{ host }}</template>
|
||||
<template #caption>
|
||||
<button class="_textButton" type="button" tabindex="-1" @click="loginWithEmailAddress = !loginWithEmailAddress">{{ loginWithEmailAddress ? i18n.ts.usernameLogin : i18n.ts.emailAddressLogin }}</button>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkInput v-if="!user || user && !user.usePasswordLessLogin" 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>
|
||||
<template #caption>
|
||||
<button class="_textButton" type="button" tabindex="-1" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
|
||||
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
|
||||
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
|
||||
<MkButton type="submit" large primary rounded :disabled="!user || captchaFailed || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
|
||||
<MkButton type="submit" large primary rounded :disabled="(!loginWithEmailAddress && !user) || captchaFailed || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
|
||||
</div>
|
||||
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
|
||||
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
|
||||
|
@ -70,6 +78,7 @@ import { instance } from '@/instance.js';
|
|||
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
|
||||
|
||||
const signing = ref(false);
|
||||
const loginWithEmailAddress = ref(false);
|
||||
const userAbortController = ref<AbortController>();
|
||||
const user = ref<Misskey.entities.UserDetailed | null>(null);
|
||||
const username = ref('');
|
||||
|
@ -119,7 +128,9 @@ const props = defineProps({
|
|||
},
|
||||
});
|
||||
|
||||
function onUsernameChange(): void {
|
||||
async function onUsernameChange(): Promise<void> {
|
||||
if (loginWithEmailAddress.value) return;
|
||||
|
||||
if (userAbortController.value) {
|
||||
userAbortController.value.abort();
|
||||
}
|
||||
|
@ -168,8 +179,15 @@ async function queryKey(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
function onSubmit(): void {
|
||||
async function onSubmit(): Promise<void> {
|
||||
signing.value = true;
|
||||
if (loginWithEmailAddress.value) {
|
||||
user.value = await misskeyApi('users/get-security-info', {
|
||||
email: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (!totpLogin.value && user.value?.twoFactorEnabled) {
|
||||
if (webAuthnSupported() && user.value.securityKeys) {
|
||||
misskeyApi('signin', {
|
||||
|
|
|
@ -27,7 +27,7 @@ import { i18n } from '@/i18n.js';
|
|||
|
||||
withDefaults(defineProps<{
|
||||
autoSet?: boolean;
|
||||
message?: string,
|
||||
message?: string;
|
||||
}>(), {
|
||||
autoSet: false,
|
||||
message: '',
|
||||
|
|
|
@ -1776,6 +1776,8 @@ declare namespace entities {
|
|||
UsersSearchResponse,
|
||||
UsersShowRequest,
|
||||
UsersShowResponse,
|
||||
UsersGetSecurityInfoRequest,
|
||||
UsersGetSecurityInfoResponse,
|
||||
UsersStatsRequest,
|
||||
UsersStatsResponse,
|
||||
UsersAchievementsRequest,
|
||||
|
@ -3166,6 +3168,12 @@ type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-
|
|||
// @public (undocumented)
|
||||
type UsersGetFrequentlyRepliedUsersResponse = operations['users___get-frequently-replied-users']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetSecurityInfoRequest = operations['users___get-security-info']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetSecurityInfoResponse = operations['users___get-security-info']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type UsersGetSkebStatusRequest = operations['users___get-skeb-status']['requestBody']['content']['application/json'];
|
||||
|
||||
|
|
|
@ -4167,6 +4167,17 @@ declare module '../api.js' {
|
|||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *No*
|
||||
*/
|
||||
request<E extends 'users/get-security-info', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* Show statistics about a user.
|
||||
*
|
||||
|
|
|
@ -562,6 +562,8 @@ import type {
|
|||
UsersSearchResponse,
|
||||
UsersShowRequest,
|
||||
UsersShowResponse,
|
||||
UsersGetSecurityInfoRequest,
|
||||
UsersGetSecurityInfoResponse,
|
||||
UsersStatsRequest,
|
||||
UsersStatsResponse,
|
||||
UsersAchievementsRequest,
|
||||
|
@ -964,6 +966,7 @@ export type Endpoints = {
|
|||
'users/search-by-username-and-host': { req: UsersSearchByUsernameAndHostRequest; res: UsersSearchByUsernameAndHostResponse };
|
||||
'users/search': { req: UsersSearchRequest; res: UsersSearchResponse };
|
||||
'users/show': { req: UsersShowRequest; res: UsersShowResponse };
|
||||
'users/get-security-info': { req: UsersGetSecurityInfoRequest; res: UsersGetSecurityInfoResponse };
|
||||
'users/stats': { req: UsersStatsRequest; res: UsersStatsResponse };
|
||||
'users/achievements': { req: UsersAchievementsRequest; res: UsersAchievementsResponse };
|
||||
'users/update-memo': { req: UsersUpdateMemoRequest; res: EmptyResponse };
|
||||
|
|
|
@ -565,6 +565,8 @@ export type UsersSearchRequest = operations['users___search']['requestBody']['co
|
|||
export type UsersSearchResponse = operations['users___search']['responses']['200']['content']['application/json'];
|
||||
export type UsersShowRequest = operations['users___show']['requestBody']['content']['application/json'];
|
||||
export type UsersShowResponse = operations['users___show']['responses']['200']['content']['application/json'];
|
||||
export type UsersGetSecurityInfoRequest = operations['users___get-security-info']['requestBody']['content']['application/json'];
|
||||
export type UsersGetSecurityInfoResponse = operations['users___get-security-info']['responses']['200']['content']['application/json'];
|
||||
export type UsersStatsRequest = operations['users___stats']['requestBody']['content']['application/json'];
|
||||
export type UsersStatsResponse = operations['users___stats']['responses']['200']['content']['application/json'];
|
||||
export type UsersAchievementsRequest = operations['users___achievements']['requestBody']['content']['application/json'];
|
||||
|
|
|
@ -3587,6 +3587,15 @@ export type paths = {
|
|||
*/
|
||||
post: operations['users___show'];
|
||||
};
|
||||
'/users/get-security-info': {
|
||||
/**
|
||||
* users/get-security-info
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *No*
|
||||
*/
|
||||
post: operations['users___get-security-info'];
|
||||
};
|
||||
'/users/stats': {
|
||||
/**
|
||||
* users/stats
|
||||
|
@ -29174,6 +29183,70 @@ export type operations = {
|
|||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* users/get-security-info
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *No*
|
||||
*/
|
||||
'users___get-security-info': {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
twoFactorEnabled: boolean;
|
||||
usePasswordLessLogin: boolean;
|
||||
securityKeys: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description To many requests */
|
||||
429: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* users/stats
|
||||
* @description Show statistics about a user.
|
||||
|
|
Loading…
Reference in a new issue