enhance: アカウント削除時のクライアントの挙動をいい感じにするなど (#10002)

* refreshAccounts
Resolve #9322

* アカウント管理画面でリストを更新するように

* Update packages/frontend/src/account.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* ✌️

* クライアント起動時は現在ログインしているアカウントのみリフレッシュする

* clean up

* なんかめっちゃ変えた

* refactor

* refactor

* fix lint

---------

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
This commit is contained in:
tamaina 2023-03-09 14:27:16 +09:00 committed by GitHub
parent a4ca127ebd
commit c75afad64a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 149 additions and 94 deletions

View File

@ -155,6 +155,7 @@ flagShowTimelineReplies: "タイムラインにノートへの返信を表示す
flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。"
autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認"
addAccount: "アカウントを追加" addAccount: "アカウントを追加"
reloadAccountsList: "アカウントリストの情報を更新"
loginFailed: "ログインに失敗しました" loginFailed: "ログインに失敗しました"
showOnRemote: "リモートで表示" showOnRemote: "リモートで表示"
general: "全般" general: "全般"
@ -546,6 +547,10 @@ userSuspended: "このユーザーは凍結されています。"
userSilenced: "このユーザーはサイレンスされています。" userSilenced: "このユーザーはサイレンスされています。"
yourAccountSuspendedTitle: "アカウントが凍結されています" yourAccountSuspendedTitle: "アカウントが凍結されています"
yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。" yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。"
tokenRevoked: "トークンが無効です"
tokenRevokedDescription: "ログイントークンが失効しています。ログインし直してください。"
accountDeleted: "アカウントは削除されています"
accountDeletedDescription: "このアカウントは削除されています。"
menu: "メニュー" menu: "メニュー"
divider: "分割線" divider: "分割線"
addItem: "項目を追加" addItem: "項目を追加"

View File

@ -75,7 +75,7 @@ export class ApiCallService implements OnApplicationShutdown {
} }
this.send(reply, res); this.send(reply, res);
}).catch((err: ApiError) => { }).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) { if (user) {
@ -129,7 +129,7 @@ export class ApiCallService implements OnApplicationShutdown {
}, request).then((res) => { }, request).then((res) => {
this.send(reply, res); this.send(reply, res);
}).catch((err: ApiError) => { }).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) { if (user) {
@ -321,7 +321,7 @@ export class ApiCallService implements OnApplicationShutdown {
// API invoking // API invoking
return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { 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; throw err;
} else { } else {
const errId = uuid(); const errId = uuid();

View File

@ -3,6 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../error.js';
export const meta = { export const meta = {
tags: ['account'], tags: ['account'],
@ -14,6 +15,15 @@ export const meta = {
optional: false, nullable: false, optional: false, nullable: false,
ref: 'MeDetailed', ref: 'MeDetailed',
}, },
errors: {
userIsDeleted: {
message: 'User is deleted.',
code: 'USER_IS_DELETED',
id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a',
kind: 'permission',
},
}
} as const; } as const;
export const paramDef = { export const paramDef = {
@ -41,13 +51,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
// 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得
const userProfile = await this.userProfilesRepository.findOneOrFail({ const userProfile = await this.userProfilesRepository.findOne({
where: { where: {
userId: user.id, userId: user.id,
}, },
relations: ['user'], relations: ['user'],
}); });
if (userProfile == null) {
throw new ApiError(meta.errors.userIsDeleted);
}
if (!userProfile.loggedInDates.includes(today)) { if (!userProfile.loggedInDates.includes(today)) {
this.userProfilesRepository.update({ userId: user.id }, { this.userProfilesRepository.update({ userId: user.id }, {
loggedInDates: [...userProfile.loggedInDates, today], loggedInDates: [...userProfile.loggedInDates, today],

View File

@ -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 { export class ApiError extends Error {
public message: string; public message: string;

View File

@ -1,4 +1,4 @@
import { defineAsyncComponent, reactive } from 'vue'; import { defineAsyncComponent, reactive, ref } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n'; import { i18n } from './i18n';
@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy';
import { apiUrl } from '@/config'; import { apiUrl } from '@/config';
import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { waiting, api, popup, popupMenu, success, alert } from '@/os';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
import { MenuButton } from './types/menu';
// TODO: 他のタブと永続化されたstateを同期 // TODO: 他のタブと永続化されたstateを同期
@ -26,11 +27,11 @@ export function incNotesCount() {
} }
export async function signout() { export async function signout() {
if (!$i) return;
waiting(); waiting();
miLocalStorage.removeItem('account'); miLocalStorage.removeItem('account');
await removeAccount($i.id); await removeAccount($i.id);
const accounts = await getAccounts(); const accounts = await getAccounts();
//#region Remove service worker registration //#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(); 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); if (accounts.length > 0) {
else await del('accounts'); await set('accounts', accounts);
} else {
await del('accounts');
}
} }
function fetchAccount(token: string): Promise<Account> { function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
return new Promise((done, fail) => { return new Promise((done, fail) => {
// Fetch user // Fetch user
window.fetch(`${apiUrl}/i`, { window.fetch(`${apiUrl}/i`, {
@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise<Account> {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) })
.then(res => res.json()) .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
.then(res => { if (res.status >= 500 && res.status < 600) {
if (res.error) { // サーバーエラー(5xx)の場合をrejectとする
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { // 認証エラーなど4xxはresolve
showSuspendedDialog().then(() => { return fail2(res);
signout(); }
}); res.json().then(done2, fail2);
} else { }))
alert({ .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', type: 'error',
title: i18n.ts.failedToFetchAccountInformation, title: i18n.ts.accountDeleted,
text: JSON.stringify(res.error), 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 { } else {
res.token = token; await alert({
done(res); 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<Account>) {
if (!$i) return;
for (const [key, value] of Object.entries(accountData)) { for (const [key, value] of Object.entries(accountData)) {
$i[key] = value; $i[key] = value;
} }
miLocalStorage.setItem('account', JSON.stringify($i)); miLocalStorage.setItem('account', JSON.stringify($i));
} }
export function refreshAccount() { export async function refreshAccount() {
return fetchAccount($i.token).then(updateAccount); 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) { 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); 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)); miLocalStorage.setItem('account', JSON.stringify(me));
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
await addAccount(me.id, token); await addAccount(me.id, token);
@ -155,6 +210,8 @@ export async function openAccountMenu(opts: {
active?: misskey.entities.UserDetailed['id']; active?: misskey.entities.UserDetailed['id'];
onChoose?: (account: misskey.entities.UserDetailed) => void; onChoose?: (account: misskey.entities.UserDetailed) => void;
}, ev: MouseEvent) { }, ev: MouseEvent) {
if (!$i) return;
function showSigninDialog() { function showSigninDialog() {
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: res => { done: res => {
@ -175,8 +232,9 @@ export async function openAccountMenu(opts: {
async function switchAccount(account: misskey.entities.UserDetailed) { async function switchAccount(account: misskey.entities.UserDetailed) {
const storedAccounts = await getAccounts(); const storedAccounts = await getAccounts();
const token = storedAccounts.find(x => x.id === account.id).token; const found = storedAccounts.find(x => x.id === account.id);
switchAccountWithToken(token); if (found == null) return;
switchAccountWithToken(found.token);
} }
function switchAccountWithToken(token: string) { function switchAccountWithToken(token: string) {
@ -188,7 +246,7 @@ export async function openAccountMenu(opts: {
function createItem(account: misskey.entities.UserDetailed) { function createItem(account: misskey.entities.UserDetailed) {
return { return {
type: 'user', type: 'user' as const,
user: account, user: account,
active: opts.active != null ? opts.active === account.id : false, active: opts.active != null ? opts.active === account.id : false,
action: () => { 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<ReturnType<typeof createItem> | MenuButton>(res => {
accountsPromise.then(accounts => { accountsPromise.then(accounts => {
const account = accounts.find(x => x.id === a.id); 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)); res(createItem(account));
}); });
})); }));
if (opts.withExtraOperation) { if (opts.withExtraOperation) {
popupMenu([...[{ popupMenu([...[{
type: 'link', type: 'link' as const,
text: i18n.ts.profile, text: i18n.ts.profile,
to: `/@${ $i.username }`, to: `/@${ $i.username }`,
avatar: $i, avatar: $i,
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent', type: 'parent' as const,
icon: 'ti ti-plus', icon: 'ti ti-plus',
text: i18n.ts.addAccount, text: i18n.ts.addAccount,
children: [{ children: [{
@ -227,7 +292,7 @@ export async function openAccountMenu(opts: {
action: () => { createAccount(); }, action: () => { createAccount(); },
}], }],
}, { }, {
type: 'link', type: 'link' as const,
icon: 'ti ti-users', icon: 'ti ti-users',
text: i18n.ts.manageAccounts, text: i18n.ts.manageAccounts,
to: '/settings/accounts', to: '/settings/accounts',

View File

@ -20,7 +20,7 @@ import MkSignin from '@/components/MkSignin.vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{ withDefaults(defineProps<{
autoSet?: boolean; autoSet?: boolean;
message?: string, message?: string,
}>(), { }>(), {
@ -29,7 +29,7 @@ const props = withDefaults(defineProps<{
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done'): void; (ev: 'done', v: any): void;
(ev: 'closed'): void; (ev: 'closed'): void;
(ev: 'cancelled'): void; (ev: 'cancelled'): void;
}>(); }>();
@ -38,11 +38,11 @@ const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
function onClose() { function onClose() {
emit('cancelled'); emit('cancelled');
dialog.close(); if (dialog) dialog.close();
} }
function onLogin(res) { function onLogin(res) {
emit('done', res); emit('done', res);
dialog.close(); if (dialog) dialog.close();
} }
</script> </script>

View File

@ -2,21 +2,12 @@
<div class=""> <div class="">
<FormSuspense :p="init"> <FormSuspense :p="init">
<div class="_gaps"> <div class="_gaps">
<MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> <div class="_buttons">
<MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton>
<div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)"> <MkButton @click="init"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton>
<div class="avatar">
<MkAvatar :user="account" class="avatar"/>
</div>
<div class="body">
<div class="name">
<MkUserName :user="account"/>
</div>
<div class="acct">
<MkAcct :user="account"/>
</div>
</div>
</div> </div>
<MkUserCardMini v-for="user in accounts" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/>
</div> </div>
</FormSuspense> </FormSuspense>
</div> </div>
@ -30,9 +21,11 @@ import * as os from '@/os';
import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import type * as Misskey from 'misskey-js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
const storedAccounts = ref<any>(null); const storedAccounts = ref<any>(null);
const accounts = ref<any>(null); const accounts = ref<Misskey.entities.UserDetailed[]>([]);
const init = async () => { const init = async () => {
getAccounts().then(accounts => { getAccounts().then(accounts => {
@ -52,7 +45,7 @@ function menu(account, ev) {
icon: 'ti ti-switch-horizontal', icon: 'ti ti-switch-horizontal',
action: () => switchAccount(account), action: () => switchAccount(account),
}, { }, {
text: i18n.ts.remove, text: i18n.ts.logout,
icon: 'ti ti-trash', icon: 'ti ti-trash',
danger: true, danger: true,
action: () => removeAccount(account), action: () => removeAccount(account),
@ -69,23 +62,25 @@ function addAccount(ev) {
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
} }
function removeAccount(account) { async function removeAccount(account) {
_removeAccount(account.id); await _removeAccount(account.id);
accounts.value = accounts.value.filter(x => x.id !== account.id);
} }
function addExistingAccount() { function addExistingAccount() {
os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
done: res => { done: async res => {
addAccounts(res.id, res.i); await addAccounts(res.id, res.i);
os.success(); os.success();
init();
}, },
}, 'closed'); }, 'closed');
} }
function createAccount() { function createAccount() {
os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
done: res => { done: async res => {
addAccounts(res.id, res.i); await addAccounts(res.id, res.i);
switchAccountWithToken(res.i); switchAccountWithToken(res.i);
}, },
}, 'closed'); }, 'closed');
@ -111,32 +106,8 @@ definePageMetadata({
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.lcjjdxlm { .user {
display: flex; cursor: pointer;
padding: 16px;
> .avatar {
display: block;
flex-shrink: 0;
margin: 0 12px 0 0;
> .avatar {
width: 50px;
height: 50px;
}
}
> .body {
display: flex;
flex-direction: column;
justify-content: center;
width: calc(100% - 62px);
position: relative;
> .name {
font-weight: bold;
}
}
} }
</style> </style>