diff --git a/web/src/core/api/client.test.ts b/web/src/core/api/client.test.ts index fbdcdc1..b5942d9 100644 --- a/web/src/core/api/client.test.ts +++ b/web/src/core/api/client.test.ts @@ -269,6 +269,94 @@ describe('token 失效时更换身份', () => { expect(events).toEqual([]) expect(local.getItem(memberTokenKey)).toBe('good') }) + + it('修改密码时当前密码错误返回 INVALID_CREDENTIALS,不更换身份', async () => { + local.setItem(memberTokenKey, 'good') + routes.set('PUT /api/account/password', () => errorResponse(401, 'INVALID_CREDENTIALS', '当前密码错误')) + const { api, events, IdentityRenewedError } = await load() + const error: unknown = await api.changePassword({ currentPassword: 'wrong horse', newPassword: 'correct horse' }).catch((reason: unknown) => reason) + expect(error).not.toBeInstanceOf(IdentityRenewedError) + expect(error).toMatchObject({ code: 'INVALID_CREDENTIALS', status: 401 }) + expect(events).toEqual([]) + expect(local.getItem(memberTokenKey)).toBe('good') + }) + + it('账号密码错误不更换身份', async () => { + local.setItem(memberTokenKey, 'good') + routes.set('POST /api/sessions', () => errorResponse(401, 'INVALID_CREDENTIALS', '用户名或密码错误')) + const { api, events } = await load() + await expect(api.login({ username: 'alice', password: 'wrong horse' })).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS', status: 401 }) + expect(events).toEqual([]) + expect(local.getItem(memberTokenKey)).toBe('good') + }) +}) + +describe('其他标签页更换身份', () => { + let listeners: ((event: { key: string | null }) => void)[] + let changed: ReturnType void>> + + function storageEvent(key: string | null = memberTokenKey) { + for (const listener of listeners) + listener({ key }) + } + + beforeEach(() => { + listeners = [] + changed = vi.fn<() => void>() + vi.stubGlobal('window', { + addEventListener: (type: string, listener: (event: { key: string | null }) => void) => { + if (type === 'storage') + listeners.push(listener) + }, + }) + }) + + it('本标签页正在使用的 token 被换成另一个时通知切换', async () => { + local.setItem(memberTokenKey, 'good') + const { api, followMemberTokenChanges } = await load() + followMemberTokenChanges(changed) + await api.getMe() + + local.setItem(memberTokenKey, 'other') + storageEvent() + expect(changed).toHaveBeenCalledOnce() + }) + + it('token 没有变化、无关的 key 或被删除时不切换', async () => { + local.setItem(memberTokenKey, 'good') + const { api, followMemberTokenChanges } = await load() + followMemberTokenChanges(changed) + await api.getMe() + + storageEvent() + local.setItem('alisten.volume', '80') + storageEvent('alisten.volume') + local.removeItem(memberTokenKey) + storageEvent() + expect(changed).not.toHaveBeenCalled() + }) + + it('两个标签页都没有 token 各自创建游客时,先创建的另一个标签页不触发切换', async () => { + const { api, followMemberTokenChanges } = await load() + followMemberTokenChanges(changed) + const pending = api.getMe() + + local.setItem(memberTokenKey, 'other-tab') + storageEvent() + expect(changed).not.toHaveBeenCalled() + await pending + }) + + it('本标签页后写入的 token 覆盖了另一个标签页,迟到的事件不触发切换', async () => { + const { api, followMemberTokenChanges } = await load() + followMemberTokenChanges(changed) + const pending = api.getMe() + local.setItem(memberTokenKey, 'other-tab') + await pending + expect(local.getItem(memberTokenKey)).toBe('issued') + storageEvent() + expect(changed).not.toHaveBeenCalled() + }) }) describe('换取票据', () => { diff --git a/web/src/core/api/client.ts b/web/src/core/api/client.ts index 47e621a..35ad237 100644 --- a/web/src/core/api/client.ts +++ b/web/src/core/api/client.ts @@ -1,7 +1,7 @@ import type { Auth, CreateTicketRequest, CreateTicketResponse, ErrorResponse, HttpRequest } from './http.gen' import type { ErrorCode, Member } from '@/core/realtime' import { Emitter } from '@/shared/lib/emitter' -import { deleteMemberToken, loadAdminToken, loadMemberToken, saveMemberToken } from './credentials' +import { deleteMemberToken, loadAdminToken, loadMemberToken, memberTokenKey, saveMemberToken } from './credentials' import { httpClient } from './http.gen' export class ApiError extends Error { @@ -134,13 +134,17 @@ export const api = { } let creatingMember: Promise | null = null +let usedMemberToken: string | null = null function memberToken(): Promise { const stored = loadMemberToken() - if (stored) + if (stored) { + usedMemberToken = stored return Promise.resolve(stored) + } creatingMember ??= api.createMember().then(({ memberToken }) => { saveMemberToken(memberToken) + usedMemberToken = memberToken return memberToken }).finally(() => { creatingMember = null @@ -173,3 +177,15 @@ async function replaceIdentity(rejected: string): Promise { } identityEvents.emit({ kind: 'renewed', member }) } + +// 只有本标签页已经用过 token、并且保存的 token 换成了另一个时才算身份变了。 +// 两个标签页同时创建游客时,对方先写入的 token 会被后写入的覆盖,此时本标签页还没有用过 token,或用的就是最终保存的 token,都不触发,避免互相刷新。 +export function followMemberTokenChanges(onChange: () => void): void { + window.addEventListener('storage', (event) => { + if (event.key !== memberTokenKey) + return + const stored = loadMemberToken() + if (usedMemberToken !== null && stored !== null && stored !== usedMemberToken) + onChange() + }) +} diff --git a/web/src/core/api/credentials.test.ts b/web/src/core/api/credentials.test.ts new file mode 100644 index 0000000..11fe4f6 --- /dev/null +++ b/web/src/core/api/credentials.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, it, vi } from 'vitest' + +class FakeStorage { + readonly #items = new Map() + + get length(): number { + return this.#items.size + } + + key(index: number): string | null { + return [...this.#items.keys()][index] ?? null + } + + getItem(key: string): string | null { + return this.#items.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.#items.set(key, value) + } + + removeItem(key: string): void { + this.#items.delete(key) + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +it('换身份时删除所有房间的票据,其他数据不动', async () => { + const local = new FakeStorage() + vi.stubGlobal('localStorage', local) + local.setItem('alisten.ticket.room-1', 't1') + local.setItem('alisten.member-token', 'm') + local.setItem('alisten.ticket.room-2', 't2') + local.setItem('alisten.volume', '80') + const { deleteAllTickets } = await import('./credentials') + deleteAllTickets() + expect(local.getItem('alisten.ticket.room-1')).toBeNull() + expect(local.getItem('alisten.ticket.room-2')).toBeNull() + expect(local.getItem('alisten.member-token')).toBe('m') + expect(local.getItem('alisten.volume')).toBe('80') +}) diff --git a/web/src/core/api/credentials.ts b/web/src/core/api/credentials.ts index f32b36e..a75de71 100644 --- a/web/src/core/api/credentials.ts +++ b/web/src/core/api/credentials.ts @@ -1,4 +1,4 @@ -const memberTokenKey = 'alisten.member-token' +export const memberTokenKey = 'alisten.member-token' const adminTokenKey = 'alisten.admin-token' const ticketKeyPrefix = 'alisten.ticket.' @@ -26,6 +26,15 @@ export function deleteTicket(roomId: string): void { localStorage.removeItem(ticketKeyPrefix + roomId) } +// 票据属于签发时的成员,换了身份之后都不能再用。倒序遍历:删除会让后面的键前移。 +export function deleteAllTickets(): void { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i) + if (key?.startsWith(ticketKeyPrefix)) + localStorage.removeItem(key) + } +} + export function loadAdminToken(): string | null { return sessionStorage.getItem(adminTokenKey) } diff --git a/web/src/core/api/index.ts b/web/src/core/api/index.ts index 5e82f02..af8e7a1 100644 --- a/web/src/core/api/index.ts +++ b/web/src/core/api/index.ts @@ -1,4 +1,4 @@ -export { api, ApiError, identityEvents, IdentityRenewedError } from './client' +export { api, ApiError, followMemberTokenChanges, identityEvents, IdentityRenewedError } from './client' export type { IdentityEvent } from './client' -export { deleteAdminToken, deleteTicket, loadAdminToken, loadTicket, saveAdminToken, saveTicket } from './credentials' +export { deleteAdminToken, deleteAllTickets, deleteMemberToken, deleteTicket, loadAdminToken, loadTicket, saveAdminToken, saveMemberToken, saveTicket } from './credentials' export type * from './http.gen' diff --git a/web/src/features/settings/account-section.tsx b/web/src/features/settings/account-section.tsx new file mode 100644 index 0000000..21843a5 --- /dev/null +++ b/web/src/features/settings/account-section.tsx @@ -0,0 +1,170 @@ +import type { FormEvent } from 'react' +import { useEffect, useId, useState } from 'react' +import { api, IdentityRenewedError } from '@/core/api' +import { errorMessage } from '@/core/errors' +import { useStore } from '@/shared/lib/use-store' +import { Modal, notificationStore } from '@/shared/ui' +import { loginWithPassword } from './password-login' +import { profileStore, switchIdentity } from './profile-store' + +type Dialog = 'register' | 'login' | 'password' | null + +const credentialTexts = { INVALID_ARGUMENT: '用户名要 3–32 位小写字母、数字、_ 或 -,密码要 8–128 位。' } + +// 对话框渲染在 portal 中,React 的事件仍会冒泡到设置页外层的表单,提交时要拦住,否则会顺带保存整页设置。 +function submitted(event: FormEvent, run: () => Promise): void { + event.preventDefault() + event.stopPropagation() + void run() +} + +export function AccountRow() { + const member = useStore(profileStore, s => s.member) + const account = useStore(profileStore, s => s.account) + const failed = useStore(profileStore, s => s.failed) + const [registrationOpen, setRegistrationOpen] = useState(false) + const [dialog, setDialog] = useState(null) + const [busy, setBusy] = useState(false) + useEffect(() => { + let active = true + api.getSite().then((site) => { + if (active) + setRegistrationOpen(site.registrationOpen) + }, () => {}) + return () => { + active = false + } + }, []) + const logout = async () => { + setBusy(true) + if (!await profileStore.logout()) + setBusy(false) + } + const description = member ? null : failed ? '暂时无法获取你的资料。' : '正在获取你的资料…' + return ( +
+
+

{account ? account.username : '游客'}

+ {description &&

{description}

} +
+ {member && ( +
+ {account + ? ( + <> + + + + ) + : ( + <> + {registrationOpen && } + + + )} +
+ )} + {(dialog === 'register' || dialog === 'login') && setDialog(null)} />} + {dialog === 'password' && setDialog(null)} />} +
+ ) +} + +function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login', onClose: () => void }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [repeated, setRepeated] = useState('') + const [busy, setBusy] = useState(false) + const register = mode === 'register' + const submit = async () => { + setBusy(true) + try { + if (register) { + const { account } = await api.register({ username, password }) + notificationStore.success('已注册') + onClose() + void profileStore.registered(account) + } + else { + switchIdentity(await loginWithPassword(username, password)) + } + } + catch (error) { + notificationStore.error(error instanceof IdentityRenewedError ? (register ? '没有注册成功,再试一次。' : '没有登录,再试一次。') : errorMessage(error, credentialTexts)) + setBusy(false) + } + } + return ( + +
submitted(event, submit)}> + + + {register && } +

+ {register + ? '用户名 3–32 位,可用小写字母、数字、_ 和 -;密码至少 8 位。忘记密码需联系管理员。' + : '登录后页面会重新加载。'} +

+ + +
+ ) +} + +function PasswordDialog({ onClose }: { onClose: () => void }) { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [repeated, setRepeated] = useState('') + const [busy, setBusy] = useState(false) + const submit = async () => { + setBusy(true) + try { + await api.changePassword({ currentPassword, newPassword }) + notificationStore.success('已修改密码,其他设备已退出登录') + onClose() + } + catch (error) { + notificationStore.error(error instanceof IdentityRenewedError ? '密码没有修改,再试一次。' : errorMessage(error, { INVALID_ARGUMENT: '密码要 8 到 128 位。' })) + setBusy(false) + } + } + return ( + +
submitted(event, submit)}> + + + +

修改后,其他设备需要重新登录。

+ + +
+ ) +} + +function RepeatPassword({ label, password, value, onChange }: { label: string, password: string, value: string, onChange: (value: string) => void }) { + const hintId = useId() + const mismatched = value !== '' && value !== password + return ( + <> + + {mismatched &&

两次输入的密码不一样。

} + + ) +} diff --git a/web/src/features/settings/password-login.test.ts b/web/src/features/settings/password-login.test.ts new file mode 100644 index 0000000..587c318 --- /dev/null +++ b/web/src/features/settings/password-login.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +class FakeStorage { + readonly #items = new Map() + + getItem(key: string): string | null { + return this.#items.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.#items.set(key, value) + } + + removeItem(key: string): void { + this.#items.delete(key) + } +} + +const devicesKey = 'alisten.login-devices' + +// 服务端发给这个账号的凭证,格式与协议中的 LoginDevice 一致。 +function deviceOf(username: string): string { + return username.padEnd(43, '0') +} + +let local: FakeStorage +let bodies: unknown[] +let rejected: boolean + +beforeEach(() => { + vi.resetModules() + local = new FakeStorage() + bodies = [] + rejected = false + vi.stubGlobal('localStorage', local) + vi.stubGlobal('sessionStorage', new FakeStorage()) + vi.stubGlobal('window', new EventTarget()) + local.setItem('alisten.member-token', 'guest') + vi.stubGlobal('fetch', async (path: string, init: RequestInit) => { + if (`${init.method} ${path}` !== 'POST /api/sessions') + return new Response(null, { status: 404 }) + const body = JSON.parse(String(init.body)) as { username: string } + bodies.push(body) + if (rejected) + return new Response(JSON.stringify({ error: { code: 'INVALID_CREDENTIALS', message: '用户名或密码错误' } }), { status: 401 }) + const { username } = body + return new Response(JSON.stringify({ + memberToken: `${username}-token`, + member: { id: username, name: username, avatarHash: '' }, + account: { username, github: false }, + device: deviceOf(username), + }), { status: 201 }) + }) +}) + +async function login(username: string): Promise { + const { loginWithPassword } = await import('./password-login') + return loginWithPassword(username, 'correct horse') +} + +function saved(): unknown { + return JSON.parse(local.getItem(devicesKey)!) +} + +describe('登录设备凭证', () => { + it('第一次登录不带凭证,保存响应里的凭证;再登录同一个账号时带上它', async () => { + expect(await login('alice')).toBe('alice-token') + expect(bodies).toEqual([{ username: 'alice', password: 'correct horse' }]) + expect(saved()).toEqual({ alice: deviceOf('alice') }) + + await login('alice') + expect(bodies[1]).toEqual({ username: 'alice', password: 'correct horse', device: deviceOf('alice') }) + }) + + it('按用户名分别保存:登录别的账号不带这个账号的凭证,也不覆盖它', async () => { + await login('alice') + await login('bob') + expect(bodies[1]).toEqual({ username: 'bob', password: 'correct horse' }) + expect(saved()).toEqual({ alice: deviceOf('alice'), bob: deviceOf('bob') }) + }) + + it('服务端不认带去的凭证、换发了新的时,保存新的', async () => { + const stale = 'x'.repeat(43) + local.setItem(devicesKey, JSON.stringify({ alice: stale })) + await login('alice') + expect(bodies).toEqual([{ username: 'alice', password: 'correct horse', device: stale }]) + expect(saved()).toEqual({ alice: deviceOf('alice') }) + }) + + it('登录失败时不改动保存的凭证', async () => { + local.setItem(devicesKey, JSON.stringify({ alice: deviceOf('alice') })) + rejected = true + await expect(login('alice')).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS' }) + await expect(login('bob')).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS' }) + expect(saved()).toEqual({ alice: deviceOf('alice') }) + }) + + it('保存的内容不是凭证时不带上,登录成功后重新保存', async () => { + for (const [stored, username] of [['not json', 'alice'], [JSON.stringify({ alice: 'too short' }), 'alice'], ['{}', 'constructor']]) { + bodies = [] + local.setItem(devicesKey, stored) + await login(username) + expect(bodies).toEqual([{ username, password: 'correct horse' }]) + expect(saved()).toEqual({ [username]: deviceOf(username) }) + } + }) +}) diff --git a/web/src/features/settings/password-login.ts b/web/src/features/settings/password-login.ts new file mode 100644 index 0000000..a242553 --- /dev/null +++ b/web/src/features/settings/password-login.ts @@ -0,0 +1,26 @@ +import { api } from '@/core/api' + +// 按用户名保存服务端发给本机的登录设备凭证:一台设备可能登录过多个账号。退出登录时保留,下次登录这个账号时带上, +// 别人反复猜这个账号的密码时本机仍然能登录。 +const devicesKey = 'alisten.login-devices' +// 与协议中的 LoginDevice 一致:本机保存的内容被改坏时不能带上,否则服务端会拒绝整个登录请求。 +const devicePattern = /^[\w-]{43}$/ + +function loadDevices(): Record { + try { + const devices: unknown = JSON.parse(localStorage.getItem(devicesKey) ?? '{}') + if (typeof devices === 'object' && devices !== null) + return devices as Record + } + catch {} + return {} +} + +// 返回账号的新成员 token。 +export async function loginWithPassword(username: string, password: string): Promise { + const device = loadDevices()[username] + const response = await api.login(typeof device === 'string' && devicePattern.test(device) ? { username, password, device } : { username, password }) + // 等待响应期间别的标签页可能保存了别的账号的凭证,重新读取之后再写回。 + localStorage.setItem(devicesKey, JSON.stringify({ ...loadDevices(), [username]: response.device })) + return response.memberToken +} diff --git a/web/src/features/settings/profile-store.test.ts b/web/src/features/settings/profile-store.test.ts new file mode 100644 index 0000000..8de1d53 --- /dev/null +++ b/web/src/features/settings/profile-store.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +class FakeStorage { + readonly #items = new Map() + + get length(): number { + return this.#items.size + } + + key(index: number): string | null { + return [...this.#items.keys()][index] ?? null + } + + getItem(key: string): string | null { + return this.#items.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.#items.set(key, value) + } + + removeItem(key: string): void { + this.#items.delete(key) + } +} + +const memberTokenKey = 'alisten.member-token' +const emailKey = 'alisten.email' +const member = { id: 'm1', name: 'alice', avatarHash: '' } +const renewed = { id: 'm2', name: '游客-1a2b', avatarHash: '' } + +let local: FakeStorage +let storageListeners: ((event: { key: string }) => void)[] +let reload: ReturnType +let me: unknown +let updates: unknown[] +let patchFails: boolean +let patchError: { status: number, code: string } | null +let extraRoutes: Map Response> + +async function load() { + const [{ profileStore, switchIdentity }, { identityEvents }] = await Promise.all([import('./profile-store'), import('@/core/api')]) + return { profileStore, switchIdentity, identityEvents } +} + +beforeEach(() => { + vi.resetModules() + // 通知按计时器消失。 + vi.useFakeTimers() + local = new FakeStorage() + reload = vi.fn() + me = { member } + updates = [] + patchFails = false + patchError = null + extraRoutes = new Map() + vi.stubGlobal('localStorage', local) + vi.stubGlobal('sessionStorage', new FakeStorage()) + storageListeners = [] + vi.stubGlobal('window', { location: { reload }, addEventListener: (_type: string, listener: (event: { key: string }) => void) => storageListeners.push(listener) }) + vi.stubGlobal('fetch', (path: string, init: RequestInit) => { + const request = `${init.method} ${path}` + if (request === 'PATCH /api/me') { + if (patchFails) + return Promise.reject(new TypeError('offline')) + updates.push(JSON.parse(String(init.body))) + if (patchError) + return Promise.resolve(new Response(JSON.stringify({ error: { code: patchError.code, message: '邮箱格式不对' } }), { status: patchError.status })) + return Promise.resolve(new Response(JSON.stringify({ member }), { status: 200 })) + } + const extra = extraRoutes.get(request) + if (extra) + return Promise.resolve(extra()) + if (request !== 'GET /api/me') + throw new Error(`没有预期的请求 ${request}`) + return Promise.resolve(new Response(JSON.stringify(me), { status: 200 })) + }) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('switchIdentity', () => { + it('换成新的 token,删除所有票据和本机的邮箱,重新加载页面', async () => { + local.setItem(memberTokenKey, 'guest') + local.setItem('alisten.ticket.r1', 't1') + local.setItem('alisten.ticket.r2', 't2') + local.setItem(emailKey, 'alice@example.com') + local.setItem('alisten.volume', '80') + const { switchIdentity } = await load() + switchIdentity('account') + + expect(local.getItem(memberTokenKey)).toBe('account') + expect(local.getItem('alisten.ticket.r1')).toBeNull() + expect(local.getItem('alisten.ticket.r2')).toBeNull() + expect(local.getItem(emailKey)).toBeNull() + expect(local.getItem('alisten.volume')).toBe('80') + expect(reload).toHaveBeenCalledOnce() + }) + + it('退出登录时删除 token,重新加载之后换成新的游客;登录设备凭证保留', async () => { + const devices = JSON.stringify({ alice: 'a'.repeat(43) }) + local.setItem(memberTokenKey, 'account') + local.setItem('alisten.ticket.r1', 't1') + local.setItem(emailKey, 'alice@example.com') + local.setItem('alisten.login-devices', devices) + const { switchIdentity } = await load() + switchIdentity(null) + + expect(local.getItem(memberTokenKey)).toBeNull() + expect(local.getItem('alisten.ticket.r1')).toBeNull() + expect(local.getItem(emailKey)).toBeNull() + expect(local.getItem('alisten.login-devices')).toBe(devices) + expect(reload).toHaveBeenCalledOnce() + }) +}) + +describe('其他标签页更换身份', () => { + it('其他标签页换了 token 之后重新加载页面', async () => { + local.setItem(memberTokenKey, 'good') + const { profileStore } = await load() + profileStore.start() + await profileStore.load() + local.setItem(memberTokenKey, 'other') + for (const listener of storageListeners) listener({ key: memberTokenKey }) + expect(reload).toHaveBeenCalledOnce() + }) +}) + +describe('退出登录', () => { + beforeEach(() => { + local.setItem(memberTokenKey, 'account') + }) + + it('成功后删除 token 并重新加载页面', async () => { + extraRoutes.set('DELETE /api/sessions/current', () => new Response(null, { status: 204 })) + const { profileStore } = await load() + await profileStore.logout() + expect(local.getItem(memberTokenKey)).toBeNull() + expect(reload).toHaveBeenCalledOnce() + }) + + it('会话已经失效、客户端换成了新的游客时,重新加载页面', async () => { + extraRoutes.set('DELETE /api/sessions/current', () => new Response(JSON.stringify({ error: { code: 'UNAUTHORIZED', message: '身份已失效' } }), { status: 401 })) + extraRoutes.set('POST /api/members', () => new Response(JSON.stringify({ member: renewed, memberToken: 'guest' }), { status: 200 })) + me = { member: renewed } + const { profileStore } = await load() + await profileStore.logout() + expect(local.getItem(memberTokenKey)).toBe('guest') + expect(reload).toHaveBeenCalledOnce() + }) + + it('其他错误不重新加载页面,token 保留', async () => { + extraRoutes.set('DELETE /api/sessions/current', () => new Response(JSON.stringify({ error: { code: 'INTERNAL', message: '出错了' } }), { status: 500 })) + const { profileStore } = await load() + expect(await profileStore.logout()).toBe(false) + expect(local.getItem(memberTokenKey)).toBe('account') + expect(reload).not.toHaveBeenCalled() + }) +}) + +describe('账号状态', () => { + beforeEach(() => { + local.setItem(memberTokenKey, 'good') + }) + + it('取回资料时一并取回账号', async () => { + me = { member, account: { username: 'alice' } } + const { profileStore } = await load() + await profileStore.load() + expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } }) + }) + + it('游客没有账号', async () => { + const { profileStore } = await load() + await profileStore.registered({ username: 'alice' }) + await profileStore.load() + expect(profileStore.state).toMatchObject({ member, account: null }) + }) + + it('注册之后设置账号', async () => { + const { profileStore } = await load() + await profileStore.load() + await profileStore.registered({ username: 'alice' }) + expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } }) + }) + + it('身份被拒绝或更换之后没有账号', async () => { + const { profileStore, identityEvents } = await load() + profileStore.start() + await profileStore.registered({ username: 'alice' }) + identityEvents.emit({ kind: 'rejected' }) + expect(profileStore.state).toMatchObject({ member: null, account: null }) + + await profileStore.registered({ username: 'alice' }) + identityEvents.emit({ kind: 'renewed', member: renewed }) + expect(profileStore.state).toMatchObject({ member: renewed, account: null }) + }) +}) + +describe('邮箱', () => { + beforeEach(() => { + local.setItem(memberTokenKey, 'good') + }) + + it('已注册时取自账号,不读本机', async () => { + local.setItem(emailKey, 'local@example.com') + me = { member, account: { username: 'alice', email: 'Alice@Example.COM' } } + const { profileStore } = await load() + profileStore.start() + await profileStore.load() + expect(profileStore.state.email).toBe('Alice@Example.COM') + + local.removeItem(emailKey) + me = { member, account: { username: 'alice' } } + await profileStore.load() + expect(profileStore.state.email).toBe('') + }) + + it('游客取自本机', async () => { + local.setItem(emailKey, 'guest@example.com') + const { profileStore } = await load() + profileStore.start() + await profileStore.load() + expect(profileStore.state.email).toBe('guest@example.com') + }) + + it('已注册时保存到账号,不写本机', async () => { + me = { member, account: { username: 'alice' } } + const { profileStore } = await load() + await profileStore.load() + + expect(await profileStore.save({ email: ' Alice@Example.COM ' })).toBe(true) + expect(updates).toEqual([{ email: ' Alice@Example.COM ' }]) + expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM' } }) + expect(local.getItem(emailKey)).toBeNull() + + expect(await profileStore.save({ email: '' })).toBe(true) + expect(profileStore.state.email).toBe('') + expect(profileStore.state.account).toEqual({ username: 'alice' }) + }) + + it('游客保存到本机', async () => { + const { profileStore } = await load() + await profileStore.load() + expect(await profileStore.save({ email: 'guest@example.com' })).toBe(true) + expect(local.getItem(emailKey)).toBe('guest@example.com') + expect(profileStore.state.email).toBe('guest@example.com') + }) + + it('注册之后把本机的邮箱存进账号,并删除本机的副本', async () => { + local.setItem(emailKey, 'guest@example.com') + const { profileStore } = await load() + profileStore.start() + await profileStore.load() + await profileStore.registered({ username: 'alice' }) + + expect(updates).toEqual([{ email: 'guest@example.com' }]) + expect(local.getItem(emailKey)).toBeNull() + expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com' } }) + }) + + it('注册时本机没有邮箱就不发请求', async () => { + const { profileStore } = await load() + await profileStore.load() + await profileStore.registered({ username: 'alice' }) + expect(updates).toEqual([]) + }) + + it('注册时转存失败,本机的副本保留,下次加载时重试并删除副本', async () => { + local.setItem(emailKey, 'guest@example.com') + patchFails = true + const { profileStore } = await load() + profileStore.start() + await profileStore.registered({ username: 'alice' }) + expect(local.getItem(emailKey)).toBe('guest@example.com') + + patchFails = false + me = { member, account: { username: 'alice' } } + await profileStore.load() + expect(updates).toEqual([{ email: 'guest@example.com' }]) + expect(local.getItem(emailKey)).toBeNull() + expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com' } }) + }) + + it('服务端判定本机的邮箱不合格时删除副本,之后加载不再转存', async () => { + local.setItem(emailKey, 'legacy') + patchError = { status: 400, code: 'INVALID_ARGUMENT' } + me = { member, account: { username: 'alice' } } + const { profileStore } = await load() + await profileStore.load() + expect(local.getItem(emailKey)).toBeNull() + expect(updates).toEqual([{ email: 'legacy' }]) + + await profileStore.load() + expect(updates).toEqual([{ email: 'legacy' }]) + }) + + it('重试仍失败时保留本机的副本,下次加载再试', async () => { + local.setItem(emailKey, 'guest@example.com') + patchFails = true + me = { member, account: { username: 'alice' } } + const { profileStore } = await load() + await profileStore.load() + expect(local.getItem(emailKey)).toBe('guest@example.com') + + patchFails = false + await profileStore.load() + expect(local.getItem(emailKey)).toBeNull() + expect(updates).toEqual([{ email: 'guest@example.com' }]) + }) + + it('已注册且本机没有副本,或账号已有邮箱时,加载不发更新请求', async () => { + me = { member, account: { username: 'alice' } } + const { profileStore } = await load() + await profileStore.load() + + local.setItem(emailKey, 'guest@example.com') + me = { member, account: { username: 'alice', email: 'alice@example.com' } } + await profileStore.load() + expect(updates).toEqual([]) + }) + + it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => { + local.setItem(emailKey, 'guest@example.com') + me = { member, account: { username: 'alice', email: 'alice@example.com' } } + const { profileStore } = await load() + await profileStore.load() + expect(await profileStore.save({ email: '' })).toBe(true) + expect(local.getItem(emailKey)).toBeNull() + + updates.length = 0 + me = { member, account: { username: 'alice' } } + await profileStore.load() + expect(updates).toEqual([]) + }) +}) diff --git a/web/src/features/settings/profile-store.ts b/web/src/features/settings/profile-store.ts index 324773d..abfe525 100644 --- a/web/src/features/settings/profile-store.ts +++ b/web/src/features/settings/profile-store.ts @@ -1,23 +1,25 @@ import type { IdentityEvent } from '@/core/api' -import type { Member } from '@/core/realtime' -import { api, identityEvents, IdentityRenewedError } from '@/core/api' +import type { Account, Member } from '@/core/realtime' +import { api, ApiError, deleteAllTickets, deleteMemberToken, followMemberTokenChanges, identityEvents, IdentityRenewedError, saveMemberToken } from '@/core/api' import { errorMessage } from '@/core/errors' import { Store } from '@/shared/lib/store' import { notificationStore } from '@/shared/ui' -// 邮箱只保存在本机,用来在设置页回显;服务端只保存它的哈希。 +// 游客的邮箱只保存在本机,服务端只保存它的哈希;已注册账号的邮箱保存在账号里。 const emailKey = 'alisten.email' -class ProfileStore extends Store<{ member: Member | null, email: string, failed: boolean }> { +class ProfileStore extends Store<{ member: Member | null, account: Account | null, email: string, failed: boolean }> { #loading: Promise | null = null constructor() { - super({ member: null, email: '', failed: false }) + super({ member: null, account: null, email: '', failed: false }) } start(): void { this.setState({ email: localStorage.getItem(emailKey) ?? '' }) identityEvents.on(this.#onIdentity) + // 其他标签页登录或退出之后,本标签页的资料、房间连接都属于旧身份,重新加载页面换成新身份。 + followMemberTokenChanges(() => window.location.reload()) } load(): Promise { @@ -27,26 +29,68 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed: return this.#loading } + // 本机已有的邮箱存进新账号,存失败只提示,不影响注册;本机的副本保留,下次加载时重试。 + async registered(account: Account): Promise { + this.setState({ account }) + await this.#moveLocalEmail() + } + // 返回 false 时,失败原因已经通过通知显示。 - async save(changes: { name?: string, email?: string }): Promise { + async logout(): Promise { try { - const { member } = await api.updateMe(changes) - const email = changes.email ?? this.state.email - localStorage.setItem(emailKey, email) - this.setState({ member, email, failed: false }) - return true + await api.logout() + switchIdentity(null) } catch (error) { - // 输入框限制了昵称和邮箱的长度,INVALID_ARGUMENT 只剩去掉首尾空白之后为空的昵称。 - notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空,也不能只有空格。' })) + // 账号在这台设备上的会话已经失效,客户端也已经换成新的游客,退出登录的目的已经达到。 + if (error instanceof IdentityRenewedError) { + window.location.reload() + return true + } + notificationStore.error(errorMessage(error)) return false } + return true + } + + // 返回 false 时,失败原因已经通过通知显示。 + async save(changes: { name?: string, email?: string }): Promise { + return await this.#update(changes) === null + } + + // 失败时返回错误,失败原因已经通过通知显示。 + async #update(changes: { name?: string, email?: string }): Promise { + try { + const { member } = await api.updateMe(changes) + const { account } = this.state + if (account) { + const email = changes.email?.trim() ?? this.state.email + // 用户对账号邮箱的修改是最终决定,本机残留的副本不能再覆盖它。 + if (changes.email !== undefined) + localStorage.removeItem(emailKey) + this.setState({ member, email, account: { username: account.username, ...(email && { email }) }, failed: false }) + } + else { + const email = changes.email ?? this.state.email + localStorage.setItem(emailKey, email) + this.setState({ member, email, failed: false }) + } + return null + } + catch (error) { + // 客户端不能按服务端的 message 区分是哪个字段不合格。 + notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' })) + return error + } } async #load(): Promise { this.setState({ failed: false }) try { - this.setState({ member: (await api.getMe()).member, failed: false }) + const { member, account } = await api.getMe() + this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', failed: false }) + if (account && !account.email) + await this.#moveLocalEmail() } catch (error) { // 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。 @@ -57,15 +101,25 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed: } } + async #moveLocalEmail(): Promise { + const email = localStorage.getItem(emailKey) + if (!email) + return + const error = await this.#update({ email }) + // 服务端不接受的副本重试也不会成功,删掉免得每次加载都报错;其他错误(网络、服务端故障)留到下次再试。 + if (error instanceof ApiError && error.code === 'INVALID_ARGUMENT') + localStorage.removeItem(emailKey) + } + #onIdentity = (event: IdentityEvent): void => { switch (event.kind) { case 'rejected': // 本机保存的邮箱属于原来的身份。 localStorage.removeItem(emailKey) - this.setState({ member: null, email: '', failed: false }) + this.setState({ member: null, account: null, email: '', failed: false }) return case 'renewed': - this.setState({ member: event.member, failed: false }) + this.setState({ member: event.member, account: null, failed: false }) notificationStore.info('原来的身份已经失效,已为你换了一个新的身份。', 8000) return case 'renewalFailed': @@ -76,3 +130,14 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed: } export const profileStore = new ProfileStore() + +// 换了身份之后,进房票据和本机保存的邮箱都属于原来的身份,一并清除;重新加载页面,让房间连接和各处的状态都从新身份开始。 +export function switchIdentity(token: string | null): void { + if (token === null) + deleteMemberToken() + else + saveMemberToken(token) + deleteAllTickets() + localStorage.removeItem(emailKey) + window.location.reload() +} diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx index ac84d20..178d506 100644 --- a/web/src/features/settings/settings-page.tsx +++ b/web/src/features/settings/settings-page.tsx @@ -7,6 +7,7 @@ import { historyStore, searchLimits } from '@/features/history' import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback' import { useStore } from '@/shared/lib/use-store' import { notificationStore, Select } from '@/shared/ui' +import { AccountRow } from './account-section' import { appearanceStore, fontSizes, themes } from './appearance-store' import { performanceLevels, performanceStore } from './performance-store' import { profileStore } from './profile-store' @@ -68,6 +69,7 @@ export function SettingsPage() { const headingRef = useRef(null) const member = useStore(profileStore, s => s.member) const email = useStore(profileStore, s => s.email) + const account = useStore(profileStore, s => s.account) const profileFailed = useStore(profileStore, s => s.failed) const theme = useStore(appearanceStore, s => s.theme) const fontSize = useStore(appearanceStore, s => s.fontSize) @@ -177,15 +179,16 @@ export function SettingsPage() {
-
-

个人资料

+
+

账号

+ {!member && profileFailed ? : update('name', event.target.value)} disabled={!member} required maxLength={80} autoComplete="nickname" />} - + update('email', event.target.value)} disabled={!member} maxLength={320} autoComplete="email" placeholder="name@example.com" />
diff --git a/web/src/features/settings/settings.css b/web/src/features/settings/settings.css index d12db87..e811fb6 100644 --- a/web/src/features/settings/settings.css +++ b/web/src/features/settings/settings.css @@ -33,6 +33,7 @@ .settings-page-footer { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-shrink: 0; width: 100%; max-width: 1120px; margin: 0 auto; padding: 16px 40px 24px; } .settings-save-status { flex: 1; min-width: 0; overflow-wrap: anywhere; } .settings-page-footer .button-primary { min-width: 96px; } +.settings-account-actions { flex-wrap: wrap; gap: 8px; } @container settings (max-width: 640px) { .settings-page-header { padding: 20px 20px 16px; } .settings-page-body { padding: 12px 16px 32px; }