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

View File

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

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-adaptive-bg :class="[$style.root]">
<MkAvatar :class="$style.avatar" :user="user" indicator/>
<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>
</div>
<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;
}
/** 現在のアカウントに紐づくデータをデバイスから削除します */
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を作ります
* vue上で設定コントロールのmodelとして使う用

View File

@ -8,11 +8,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<div class="_buttons">
<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>-->
</div>
<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>
</div>
</SearchMarker>
@ -20,36 +32,69 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
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 MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.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 { definePage } from '@/page.js';
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() {
// TODO
}
function showMenu(host: string, id: string, ev: MouseEvent) {
let menu: MenuItem[];
function showMenu(a: AccountData, ev: MouseEvent) {
const menu: MenuItem[] = [];
menu = [{
text: i18n.ts.switch,
icon: 'ti ti-switch-horizontal',
action: () => switchAccount(host, id),
}, {
text: i18n.ts.remove,
icon: 'ti ti-trash',
action: () => removeAccount(host, id),
}];
if ($i != null && $i.id === a.id && ($i.host ?? local) === a.host) {
menu.push({
text: i18n.ts.logout,
icon: 'ti ti-power',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
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);
}
@ -64,20 +109,30 @@ function addAccount(ev: MouseEvent) {
}], ev.currentTarget ?? ev.target);
}
function addExistingAccount() {
getAccountWithSigninDialog().then((res) => {
if (res != null) {
os.success();
}
});
async function addExistingAccount() {
const res = await getAccountWithSigninDialog();
if (res != null) {
os.success();
}
accounts.value = await getAccounts();
}
function createAccount() {
getAccountWithSignupDialog().then((res) => {
if (res != null) {
login(res.token);
}
async function createAccount() {
const res = await getAccountWithSignupDialog()
if (res != null) {
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(() => []);
@ -95,6 +150,16 @@ definePage(() => ({
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 {
display: flex;
align-items: center;

View File

@ -94,7 +94,9 @@ export type StorageProvider = {
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
default: Default;
/** アカウントごとに異なる設定値をもたせるかどうか */
accountDependent?: boolean;
/** サーバーごとに異なる設定値をもたせるかどうか(他のサーバーを同一クライアントから操作できるようになった際に使用) */
serverDependent?: boolean;
mergeStrategy?: (a: T, b: T) => T;
};
@ -447,6 +449,34 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
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 {
return this.getMatchedRecordOf(key)[2].sync ?? false;
}

View File

@ -5,20 +5,18 @@
import { apiUrl } from '@@/js/config.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 { prefer } from '@/preferences.js';
import { waiting } from '@/os.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { clear } from '@/utility/idb-proxy.js';
import { $i } from '@/i.js';
export async function signout() {
if (!$i) return;
waiting();
if (store.s.enablePreferencesAutoCloudBackup) {
await cloudBackup();
}
/** クライアントに保存しているすべてのデータを削除します。 */
async function removeAllData() {
if ($i == null) return;
localStorage.clear();
@ -74,6 +72,54 @@ export async function signout() {
// nothing
}
//#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('/');
}

View File

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

View File

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