diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 750da3e98..13f8bc02e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -155,6 +155,7 @@ flagShowTimelineReplies: "タイムラインにノートへの返信を表示す flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" addAccount: "アカウントを追加" +reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" general: "全般" @@ -546,6 +547,10 @@ userSuspended: "このユーザーは凍結されています。" userSilenced: "このユーザーはサイレンスされています。" yourAccountSuspendedTitle: "アカウントが凍結されています" yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。" +tokenRevoked: "トークンが無効です" +tokenRevokedDescription: "ログイントークンが失効しています。ログインし直してください。" +accountDeleted: "アカウントは削除されています" +accountDeletedDescription: "このアカウントは削除されています。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index f84a3aa59..bf5cb2091 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -75,7 +75,7 @@ export class ApiCallService implements OnApplicationShutdown { } this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -129,7 +129,7 @@ export class ApiCallService implements OnApplicationShutdown { }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -321,7 +321,7 @@ export class ApiCallService implements OnApplicationShutdown { // API invoking return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError) { + if (err instanceof ApiError || err instanceof AuthenticationError) { throw err; } else { const errId = uuid(); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 6beef5ab8..a3e3e02a1 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -3,6 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../error.js'; export const meta = { tags: ['account'], @@ -14,6 +15,15 @@ export const meta = { optional: false, nullable: false, ref: 'MeDetailed', }, + + errors: { + userIsDeleted: { + message: 'User is deleted.', + code: 'USER_IS_DELETED', + id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a', + kind: 'permission', + }, + } } as const; export const paramDef = { @@ -41,13 +51,17 @@ export default class extends Endpoint { const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 - const userProfile = await this.userProfilesRepository.findOneOrFail({ + const userProfile = await this.userProfilesRepository.findOne({ where: { userId: user.id, }, relations: ['user'], }); + if (userProfile == null) { + throw new ApiError(meta.errors.userIsDeleted); + } + if (!userProfile.loggedInDates.includes(today)) { this.userProfilesRepository.update({ userId: user.id }, { loggedInDates: [...userProfile.loggedInDates, today], diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 347d5650a..34f452160 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -1,4 +1,4 @@ -type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; +type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number }; export class ApiError extends Error { public message: string; diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 610212b6e..9b104391d 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -1,4 +1,4 @@ -import { defineAsyncComponent, reactive } from 'vue'; +import { defineAsyncComponent, reactive, ref } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; @@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { MenuButton } from './types/menu'; // TODO: 他のタブと永続化されたstateを同期 @@ -26,11 +27,11 @@ export function incNotesCount() { } export async function signout() { + if (!$i) return; + waiting(); miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); //#region Remove service worker registration @@ -76,15 +77,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } -export async function removeAccount(id: Account['id']) { +export async function removeAccount(idOrToken: Account['id']) { const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === id), 1); + const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); + if (i !== -1) accounts.splice(i, 1); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); + if (accounts.length > 0) { + await set('accounts', accounts); + } else { + await del('accounts'); + } } -function fetchAccount(token: string): Promise { +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { return new Promise((done, fail) => { // Fetch user window.fetch(`${apiUrl}/i`, { @@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise { 'Content-Type': 'application/json', }, }) - .then(res => res.json()) - .then(res => { - if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - showSuspendedDialog().then(() => { - signout(); - }); - } else { - alert({ + .then(res => new Promise }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, }); } } else { - res.token = token; - done(res); + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); } - }) - .catch(fail); + + // rejectかつ理由がtrueの場合、削除対象であることを示す + fail(true); + } else { + (res as Account).token = token; + done(res as Account); + } + }) + .catch(fail); }); } -export function updateAccount(accountData) { +export function updateAccount(accountData: Partial) { + if (!$i) return; for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } miLocalStorage.setItem('account', JSON.stringify($i)); } -export function refreshAccount() { - return fetchAccount($i.token).then(updateAccount); +export async function refreshAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id) + .then(updateAccount, reason => { + if (reason === true) return signout(); + return; + }); } export async function login(token: Account['token'], redirect?: string) { - waiting(); + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, {}, 'closed'); if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token); + const me = await fetchAccount(token, undefined, true) + .catch(reason => { + if (reason === true) { + // 削除対象の場合 + removeAccount(token); + } + + showing.value = false; + throw reason; + }); miLocalStorage.setItem('account', JSON.stringify(me)); document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う await addAccount(me.id, token); @@ -155,6 +210,8 @@ export async function openAccountMenu(opts: { active?: misskey.entities.UserDetailed['id']; onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { + if (!$i) return; + function showSigninDialog() { popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { @@ -175,8 +232,9 @@ export async function openAccountMenu(opts: { async function switchAccount(account: misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); - const token = storedAccounts.find(x => x.id === account.id).token; - switchAccountWithToken(token); + const found = storedAccounts.find(x => x.id === account.id); + if (found == null) return; + switchAccountWithToken(found.token); } function switchAccountWithToken(token: string) { @@ -188,7 +246,7 @@ export async function openAccountMenu(opts: { function createItem(account: misskey.entities.UserDetailed) { return { - type: 'user', + type: 'user' as const, user: account, active: opts.active != null ? opts.active === account.id : false, action: () => { @@ -201,22 +259,29 @@ export async function openAccountMenu(opts: { }; } - const accountItemPromises = storedAccounts.map(a => new Promise(res => { + const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => { accountsPromise.then(accounts => { const account = accounts.find(x => x.id === a.id); - if (account == null) return res(null); + if (account == null) return res({ + type: 'button' as const, + text: a.id, + action: () => { + switchAccountWithToken(a.token); + }, + }); + res(createItem(account)); }); })); if (opts.withExtraOperation) { popupMenu([...[{ - type: 'link', + type: 'link' as const, text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent', + type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, children: [{ @@ -227,7 +292,7 @@ export async function openAccountMenu(opts: { action: () => { createAccount(); }, }], }, { - type: 'link', + type: 'link' as const, icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 83506b8f6..08e41d6ae 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -20,7 +20,7 @@ import MkSignin from '@/components/MkSignin.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n'; -const props = withDefaults(defineProps<{ +withDefaults(defineProps<{ autoSet?: boolean; message?: string, }>(), { @@ -29,7 +29,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done'): void; + (ev: 'done', v: any): void; (ev: 'closed'): void; (ev: 'cancelled'): void; }>(); @@ -38,11 +38,11 @@ const dialog = $shallowRef>(); function onClose() { emit('cancelled'); - dialog.close(); + if (dialog) dialog.close(); } function onLogin(res) { emit('done', res); - dialog.close(); + if (dialog) dialog.close(); } diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index a5eaae2ba..a58e74fe6 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -2,21 +2,12 @@
- {{ i18n.ts.addAccount }} - -
-
- -
-
-
- -
-
- -
-
+
+ {{ i18n.ts.addAccount }} + {{ i18n.ts.reloadAccountsList }}
+ +
@@ -30,9 +21,11 @@ import * as os from '@/os'; import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import type * as Misskey from 'misskey-js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; const storedAccounts = ref(null); -const accounts = ref(null); +const accounts = ref([]); const init = async () => { getAccounts().then(accounts => { @@ -52,7 +45,7 @@ function menu(account, ev) { icon: 'ti ti-switch-horizontal', action: () => switchAccount(account), }, { - text: i18n.ts.remove, + text: i18n.ts.logout, icon: 'ti ti-trash', danger: true, action: () => removeAccount(account), @@ -69,23 +62,25 @@ function addAccount(ev) { }], ev.currentTarget ?? ev.target); } -function removeAccount(account) { - _removeAccount(account.id); +async function removeAccount(account) { + await _removeAccount(account.id); + accounts.value = accounts.value.filter(x => x.id !== account.id); } function addExistingAccount() { os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: res => { - addAccounts(res.id, res.i); + done: async res => { + await addAccounts(res.id, res.i); os.success(); + init(); }, }, 'closed'); } function createAccount() { os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: res => { - addAccounts(res.id, res.i); + done: async res => { + await addAccounts(res.id, res.i); switchAccountWithToken(res.i); }, }, 'closed'); @@ -111,32 +106,8 @@ definePageMetadata({ }); -