fix(frontend): ログアウトするとすべてのアカウントからログアウトされる問題を修正

This commit is contained in:
kakkokari-gtyih 2025-12-27 15:09:16 +09:00
parent bc78bb9b8e
commit d2ce24dcc6
9 changed files with 248 additions and 48 deletions

View File

@ -34,6 +34,7 @@ noAccountDescription: "自己紹介はありません"
login: "ログイン" login: "ログイン"
loggingIn: "ログイン中" loggingIn: "ログイン中"
logout: "ログアウト" logout: "ログアウト"
logoutFromAll: "すべてのアカウントからログアウト"
signup: "新規登録" signup: "新規登録"
uploading: "アップロード中" uploading: "アップロード中"
save: "保存" save: "保存"
@ -991,6 +992,8 @@ numberOfPageCache: "ページキャッシュ数"
numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。"
logoutConfirm: "ログアウトしますか?" logoutConfirm: "ログアウトしますか?"
logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。" logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。"
removeAccountConfirm: "{username}からログアウトしますか?"
removeAccountWillClearClientData: "このアカウントを削除すると、このアカウントに関するクライアントの設定情報がブラウザから消去されます。再度このアカウントでログインする場合、設定情報を復元できるようにするためには、このアカウントに切り替えて、設定の自動バックアップを有効にしてください。"
lastActiveDate: "最終利用日時" lastActiveDate: "最終利用日時"
statusbar: "ステータスバー" statusbar: "ステータスバー"
pleaseSelect: "選択してください" pleaseSelect: "選択してください"

View File

@ -19,13 +19,15 @@ import { signout } from '@/signout.js';
type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
export async function getAccounts(): Promise<{ export type AccountData = {
host: string; host: string;
id: Misskey.entities.User['id']; id: Misskey.entities.User['id'];
username: Misskey.entities.User['username']; username: Misskey.entities.User['username'];
user?: Misskey.entities.MeDetailed | null; user?: Misskey.entities.MeDetailed | null;
token: string | null; token: string | null;
}[]> { };
export async function getAccounts(): Promise<AccountData[]> {
const tokens = store.s.accountTokens; const tokens = store.s.accountTokens;
const accountInfos = store.s.accountInfos; const accountInfos = store.s.accountInfos;
const accounts = prefer.s.accounts; const accounts = prefer.s.accounts;
@ -162,14 +164,17 @@ export async function refreshCurrentAccount() {
}); });
} }
export async function login(token: AccountWithToken['token'], redirect?: string) { export async function login(token: AccountWithToken['token'], redirect?: string, showWaiting = true) {
const showing = ref(true); const showing = ref(true);
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
success: false, if (showWaiting) {
showing: showing, const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
}, { success: false,
closed: () => dispose(), showing: showing,
}); }, {
closed: () => dispose(),
});
}
const me = await fetchAccount(token, undefined, true).catch(reason => { const me = await fetchAccount(token, undefined, true).catch(reason => {
showing.value = false; showing.value = false;

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-adaptive-bg :class="[$style.root]"> <div v-adaptive-bg :class="[$style.root]">
<MkAvatar :class="$style.avatar" :user="user" indicator/> <MkAvatar :class="$style.avatar" :user="user" indicator/>
<div :class="$style.body"> <div :class="$style.body">
<span :class="$style.name"><MkUserName :user="user"/></span> <span :class="$style.name"><MkUserName :user="user"/><slot name="nameSuffix"></slot></span>
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span> <span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
</div> </div>
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/> <MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>

View File

@ -222,6 +222,45 @@ export class Pizzax<T extends StateDef> {
return this.def[key].default; return this.def[key].default;
} }
/** 現在のアカウントに紐づくデータをデバイスから削除します */
public async clearCurrentAccountDataFromDevice() {
if ($i == null) return;
// deviceAccount
{
const deviceAccountState = await get(this.deviceAccountStateKeyName) || {};
let changed = false;
for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) {
if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
delete deviceAccountState[k];
changed = true;
}
}
if (changed) {
await this.addIdbSetJob(async () => {
await set(this.deviceAccountStateKeyName, deviceAccountState);
});
}
}
// account (cacheを消す)
{
const registryCache = await get(this.registryCacheKeyName) || {};
let changed = false;
for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) {
if (v.where === 'account' && Object.prototype.hasOwnProperty.call(registryCache, k)) {
delete registryCache[k];
changed = true;
}
}
if (changed) {
await this.addIdbSetJob(async () => {
await set(this.registryCacheKeyName, registryCache);
});
}
}
}
/** /**
* getter/setterを作ります * getter/setterを作ります
* vue上で設定コントロールのmodelとして使う用 * vue上で設定コントロールのmodelとして使う用

View File

@ -8,11 +8,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps"> <div class="_gaps">
<div class="_buttons"> <div class="_buttons">
<MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton>
<MkButton danger @click="logoutFromAll"><i class="ti ti-power"></i> {{ i18n.ts.logoutFromAll }}</MkButton>
<!--<MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i></MkButton>--> <!--<MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i></MkButton>-->
</div> </div>
<template v-for="x in accounts" :key="x.host + x.id"> <template v-for="x in accounts" :key="x.host + x.id">
<MkUserCardMini v-if="x.user" :user="x.user" :class="$style.user" @click.prevent="showMenu(x.host, x.id, $event)"/> <MkUserCardMini v-if="x.user" :user="x.user" :class="$style.user" @click.prevent="showMenu(x, $event)">
<template #nameSuffix>
<span v-if="x.id === $i?.id" :class="$style.currentAccountTag">{{ i18n.ts.loggingIn }}</span>
</template>
</MkUserCardMini>
<button v-else v-panel class="_button" :class="$style.unknownUser" @click="showMenu(x, $event)">
<div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div>
<div>
<div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}<span v-if="x.id === $i?.id" :class="$style.currentAccountTag">{{ i18n.ts.loggingIn }}</span></div>
<div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ x.id }}</span></div>
</div>
</button>
</template> </template>
</div> </div>
</SearchMarker> </SearchMarker>
@ -20,36 +32,69 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js'; import { host as local } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccounts } from '@/accounts.js'; import { switchAccount, removeAccount, getAccountWithSigninDialog, getAccountWithSignupDialog, getAccounts } from '@/accounts.js';
import type { AccountData } from '@/accounts.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { prefer } from '@/preferences.js'; import { signout } from '@/signout.js';
const accounts = await getAccounts(); const accounts = ref<AccountData[]>([]);
getAccounts().then((res) => {
accounts.value = res;
});
function refreshAllAccounts() { function refreshAllAccounts() {
// TODO // TODO
} }
function showMenu(host: string, id: string, ev: MouseEvent) { function showMenu(a: AccountData, ev: MouseEvent) {
let menu: MenuItem[]; const menu: MenuItem[] = [];
menu = [{ if ($i != null && $i.id === a.id && ($i.host ?? local) === a.host) {
text: i18n.ts.switch, menu.push({
icon: 'ti ti-switch-horizontal', text: i18n.ts.logout,
action: () => switchAccount(host, id), icon: 'ti ti-power',
}, { danger: true,
text: i18n.ts.remove, action: async () => {
icon: 'ti ti-trash', const { canceled } = await os.confirm({
action: () => removeAccount(host, id), type: 'warning',
}]; title: i18n.ts.logoutConfirm,
text: i18n.ts.logoutWillClearClientData,
});
if (canceled) return;
signout();
},
});
} else {
menu.push({
text: i18n.ts.switch,
icon: 'ti ti-switch-horizontal',
action: () => switchAccount(a.host, a.id),
}, {
text: i18n.ts.logout,
icon: 'ti ti-power',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.tsx.removeAccountConfirm({ username: `<plain>@${a.username}</plain>` }),
text: i18n.ts.removeAccountWillClearClientData,
});
if (canceled) return;
await os.promiseDialog((async () => {
await removeAccount(a.host, a.id);
accounts.value = await getAccounts();
})());
},
});
}
os.popupMenu(menu, ev.currentTarget ?? ev.target); os.popupMenu(menu, ev.currentTarget ?? ev.target);
} }
@ -64,20 +109,30 @@ function addAccount(ev: MouseEvent) {
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
} }
function addExistingAccount() { async function addExistingAccount() {
getAccountWithSigninDialog().then((res) => { const res = await getAccountWithSigninDialog();
if (res != null) { if (res != null) {
os.success(); os.success();
} }
}); accounts.value = await getAccounts();
} }
function createAccount() { async function createAccount() {
getAccountWithSignupDialog().then((res) => { const res = await getAccountWithSignupDialog()
if (res != null) { if (res != null) {
login(res.token); os.success();
} }
accounts.value = await getAccounts();
}
async function logoutFromAll() {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.ts.logoutConfirm,
text: i18n.ts.logoutWillClearClientData,
}); });
if (canceled) return;
signout(true);
} }
const headerActions = computed(() => []); const headerActions = computed(() => []);
@ -95,6 +150,16 @@ definePage(() => ({
cursor: pointer; cursor: pointer;
} }
.currentAccountTag {
display: inline-block;
margin-left: 8px;
padding: 0 6px;
font-size: 0.8em;
background: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
border-radius: calc(var(--MI-radius) / 2);
}
.unknownUser { .unknownUser {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -94,7 +94,9 @@ export type StorageProvider = {
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = { type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
default: Default; default: Default;
/** アカウントごとに異なる設定値をもたせるかどうか */
accountDependent?: boolean; accountDependent?: boolean;
/** サーバーごとに異なる設定値をもたせるかどうか(他のサーバーを同一クライアントから操作できるようになった際に使用) */
serverDependent?: boolean; serverDependent?: boolean;
mergeStrategy?: (a: T, b: T) => T; mergeStrategy?: (a: T, b: T) => T;
}; };
@ -447,6 +449,34 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
this.save(); this.save();
} }
/** 現在の操作アカウントに紐づく設定値をデバイスから削除します(ログアウト時) */
public clearCurrentAccountSettingsFromDevice() {
const currentAccount = this.currentAccount; // TSを黙らせるため
if (currentAccount == null) return;
let changed = false;
for (const _key in PREF_DEF) {
const key = _key as keyof PREF;
const records = this.profile.preferences[key];
const index = records.findIndex((record: PrefRecord<typeof key>) => {
const scope = parseScope(record[0]);
return scope.server === host && scope.account === currentAccount.id;
});
if (index === -1) continue;
records.splice(index, 1);
changed = true;
this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]);
}
if (changed) {
this.save();
}
}
public isSyncEnabled<K extends keyof PREF>(key: K): boolean { public isSyncEnabled<K extends keyof PREF>(key: K): boolean {
return this.getMatchedRecordOf(key)[2].sync ?? false; return this.getMatchedRecordOf(key)[2].sync ?? false;
} }

View File

@ -5,20 +5,18 @@
import { apiUrl } from '@@/js/config.js'; import { apiUrl } from '@@/js/config.js';
import { cloudBackup } from '@/preferences/utility.js'; import { cloudBackup } from '@/preferences/utility.js';
import { removeAccount, login } from '@/accounts.js';
import { host } from '@@/js/config.js';
import { store } from '@/store.js'; import { store } from '@/store.js';
import { prefer } from '@/preferences.js';
import { waiting } from '@/os.js'; import { waiting } from '@/os.js';
import { unisonReload } from '@/utility/unison-reload.js'; import { unisonReload } from '@/utility/unison-reload.js';
import { clear } from '@/utility/idb-proxy.js'; import { clear } from '@/utility/idb-proxy.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
export async function signout() { /** クライアントに保存しているすべてのデータを削除します。 */
if (!$i) return; async function removeAllData() {
if ($i == null) return;
waiting();
if (store.s.enablePreferencesAutoCloudBackup) {
await cloudBackup();
}
localStorage.clear(); localStorage.clear();
@ -74,6 +72,54 @@ export async function signout() {
// nothing // nothing
} }
//#endregion //#endregion
}
/** 現在のアカウントに関連するデータを削除します。 */
async function removeCurrentAccountData() {
if ($i == null) return;
// 設定・状態を削除
prefer.clearCurrentAccountSettingsFromDevice();
await store.clearCurrentAccountDataFromDevice();
}
export async function signout(all = false) {
if (!$i) return;
const currentAccountId = $i.id;
waiting();
if (store.s.enablePreferencesAutoCloudBackup) {
await cloudBackup();
}
if (prefer.s.accounts.length <= 1 || all) {
// 最後のアカウントを削除する場合・全てのアカウントからログアウトする場合は全データ削除
await removeAllData();
} else {
// 複数アカウントある場合は現在のアカウントのデータのみ削除
await removeCurrentAccountData();
// 現在のアカウント情報を削除
await removeAccount(host, $i.id);
// アカウント切り替え
const nextAccountToken = Object.entries(store.s.accountTokens).find(([key, _]) => {
const [accountHost, userId] = key.split('/');
return accountHost === host && userId !== currentAccountId;
})?.[1];
if (nextAccountToken != null) {
// ログインの際の遷移の挙動はlogin関数内で行うのでここではunisonReloadを呼ばず終了
await login(nextAccountToken, undefined, false);
return;
} else {
// 現時点では外部ホストのアカウントをログインさせることはできないので、
// 通常の全アカウントからのログアウトと同様に扱う(全データ削除)
await removeAllData();
}
}
unisonReload('/'); unisonReload('/');
} }

View File

@ -103,11 +103,11 @@ export const store = markRaw(new Pizzax('base', {
}, },
accountTokens: { accountTokens: {
where: 'device', where: 'device',
default: {} as Record<string, string>, // host/userId, token default: {} as Record<`${string}/${string}`, string>, // host/userId, token
}, },
accountInfos: { accountInfos: {
where: 'device', where: 'device',
default: {} as Record<string, Misskey.entities.MeDetailed>, // host/userId, user default: {} as Record<`${string}/${string}`, Misskey.entities.MeDetailed>, // host/userId, user
}, },
enablePreferencesAutoCloudBackup: { enablePreferencesAutoCloudBackup: {

View File

@ -148,6 +148,10 @@ export interface Locale extends ILocale {
* *
*/ */
"logout": string; "logout": string;
/**
*
*/
"logoutFromAll": string;
/** /**
* *
*/ */
@ -3976,6 +3980,14 @@ export interface Locale extends ILocale {
* *
*/ */
"logoutWillClearClientData": string; "logoutWillClearClientData": string;
/**
* {username}
*/
"removeAccountConfirm": ParameterizedString<"username">;
/**
*
*/
"removeAccountWillClearClientData": string;
/** /**
* *
*/ */