feat: register and sign in from the settings page

This commit is contained in:
DiheChen committed 2026-11-09 21:23:23 +08:00
1 parent a0956299be
commit a80c48824f
12 files changed
+892 -24

No files matched your search

+88
View File
@@ -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<typeof vi.fn<() => 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('换取票据', () => {
+18 -2
View File
@@ -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<string> | null = null
let usedMemberToken: string | null = null
function memberToken(): Promise<string> {
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<void> {
}
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()
})
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, expect, it, vi } from 'vitest'
class FakeStorage {
readonly #items = new Map<string, string>()
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')
})
+10 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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'
@@ -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>): 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<Dialog>(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 (
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">{account ? account.username : '游客'}</p>
{description && <p className="text-ui-caption muted">{description}</p>}
</div>
{member && (
<div className="settings-row-control settings-account-actions">
{account
? (
<>
<button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
</>
)
: (
<>
{registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
</>
)}
</div>
)}
{(dialog === 'register' || dialog === 'login') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
</div>
)
}
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 (
<Modal title={register ? '注册账号' : '登录'} onClose={onClose}>
<form className="form-stack" onSubmit={event => submitted(event, submit)}>
<label>
用户名
<input data-autofocus required autoComplete="username" pattern="[a-z0-9_\-]{3,32}" minLength={3} maxLength={32} value={username} onChange={event => setUsername(event.target.value.toLowerCase())} />
</label>
<label>
密码
<input type="password" required autoComplete={register ? 'new-password' : 'current-password'} minLength={8} maxLength={128} value={password} onChange={event => setPassword(event.target.value)} />
</label>
{register && <RepeatPassword label="再次输入密码" password={password} value={repeated} onChange={setRepeated} />}
<p className="text-ui-caption muted">
{register
? '用户名 3–32 位,可用小写字母、数字、_ 和 -;密码至少 8 位。忘记密码需联系管理员。'
: '登录后页面会重新加载。'}
</p>
<button type="submit" className="button-primary" disabled={busy || (register && repeated !== password)}>
{busy ? (register ? '正在注册…' : '正在登录…') : (register ? '注册' : '登录')}
</button>
</form>
</Modal>
)
}
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 (
<Modal title="修改密码" onClose={onClose}>
<form className="form-stack" onSubmit={event => submitted(event, submit)}>
<label>
当前密码
<input type="password" data-autofocus required autoComplete="current-password" minLength={8} maxLength={128} value={currentPassword} onChange={event => setCurrentPassword(event.target.value)} />
</label>
<label>
新密码
<input type="password" required autoComplete="new-password" minLength={8} maxLength={128} value={newPassword} onChange={event => setNewPassword(event.target.value)} />
</label>
<RepeatPassword label="再次输入新密码" password={newPassword} value={repeated} onChange={setRepeated} />
<p className="text-ui-caption muted">修改后,其他设备需要重新登录。</p>
<button type="submit" className="button-primary" disabled={busy || repeated !== newPassword}>{busy ? '正在修改…' : '修改密码'}</button>
</form>
</Modal>
)
}
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 (
<>
<label>
{label}
<input type="password" required autoComplete="new-password" minLength={8} maxLength={128} value={value} onChange={event => onChange(event.target.value)} aria-invalid={mismatched} aria-describedby={mismatched ? hintId : undefined} />
</label>
{mismatched && <p id={hintId} className="text-ui-caption error-text">两次输入的密码不一样。</p>}
</>
)
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
class FakeStorage {
readonly #items = new Map<string, string>()
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<string> {
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) })
}
})
})
@@ -0,0 +1,26 @@
import { api } from '@/core/api'
// 按用户名保存服务端发给本机的登录设备凭证:一台设备可能登录过多个账号。退出登录时保留,下次登录这个账号时带上,
// 别人反复猜这个账号的密码时本机仍然能登录。
const devicesKey = 'alisten.login-devices'
// 与协议中的 LoginDevice 一致:本机保存的内容被改坏时不能带上,否则服务端会拒绝整个登录请求。
const devicePattern = /^[\w-]{43}$/
function loadDevices(): Record<string, unknown> {
try {
const devices: unknown = JSON.parse(localStorage.getItem(devicesKey) ?? '{}')
if (typeof devices === 'object' && devices !== null)
return devices as Record<string, unknown>
}
catch {}
return {}
}
// 返回账号的新成员 token。
export async function loginWithPassword(username: string, password: string): Promise<string> {
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
}
@@ -0,0 +1,339 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
class FakeStorage {
readonly #items = new Map<string, string>()
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<typeof vi.fn>
let me: unknown
let updates: unknown[]
let patchFails: boolean
let patchError: { status: number, code: string } | null
let extraRoutes: Map<string, () => 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([])
})
})
+81 -16
View File
@@ -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<void> | 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<void> {
@@ -27,26 +29,68 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
return this.#loading
}
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册;本机的副本保留,下次加载时重试。
async registered(account: Account): Promise<void> {
this.setState({ account })
await this.#moveLocalEmail()
}
// 返回 false 时,失败原因已经通过通知显示。
async save(changes: { name?: string, email?: string }): Promise<boolean> {
async logout(): Promise<boolean> {
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<boolean> {
return await this.#update(changes) === null
}
// 失败时返回错误,失败原因已经通过通知显示。
async #update(changes: { name?: string, email?: string }): Promise<unknown> {
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<void> {
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<void> {
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()
}
+6 -3
View File
@@ -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<HTMLHeadingElement>(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() {
<div className="settings-page-body">
<fieldset className="settings-fields" disabled={saving}>
<div className="settings-content">
<section className="settings-section" aria-labelledby="settings-profile-title">
<h2 id="settings-profile-title" className="text-ui-lg">个人资料</h2>
<section className="settings-section" aria-labelledby="settings-account-title">
<h2 id="settings-account-title" className="text-ui-lg">账号</h2>
<div className="settings-group">
<AccountRow />
<SettingRow id="settings-name" title="昵称" description={member ? '房间里的其他人看到的名字。' : profileFailed ? '暂时无法获取你的资料。' : '正在获取你的资料…'} field>
{!member && profileFailed
? <button type="button" className="button-secondary" aria-describedby="settings-name-description" onClick={loadProfile}>重试</button>
: <input id="settings-name" aria-describedby="settings-name-description" value={draft.name} onChange={event => update('name', event.target.value)} disabled={!member} required maxLength={80} autoComplete="nickname" />}
</SettingRow>
<SettingRow id="settings-email" title="邮箱" description="只用来显示 Gravatar 头像,访问不到 Gravatar 时改用镜像 WeAvatar 或 Cravatar;服务器不会保存邮箱地址,留空就不显示头像。" field>
<SettingRow id="settings-email" title="邮箱" description={account ? '保存在账号里,用来显示头像。留空就不显示。' : '只保存在这台设备上,用来显示头像。留空就不显示。'} field>
<input id="settings-email" aria-describedby="settings-email-description" type="email" value={draft.email} onChange={event => update('email', event.target.value)} disabled={!member} maxLength={320} autoComplete="email" placeholder="name@example.com" />
</SettingRow>
</div>
+1
View File
@@ -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; }