feat: register and sign in from the settings page
This commit is contained in:
1 parent
eb4202c8ba
commit
54efdb6dd2
9 files changed
+487
-17
No files matched your search
@@ -269,6 +269,15 @@ describe('token 失效时更换身份', () => {
|
||||
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('换取票据', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { api, ApiError, 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,169 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useEffect, 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 { 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)
|
||||
try {
|
||||
await api.logout()
|
||||
switchIdentity(null)
|
||||
}
|
||||
catch (error) {
|
||||
// 账号在这台设备上的会话已经失效,客户端也已经换成新的游客,退出登录的目的已经达到。
|
||||
if (error instanceof IdentityRenewedError) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
notificationStore.error(errorMessage(error))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const description = !member
|
||||
? (failed ? '暂时无法获取你的资料。' : '正在获取你的资料…')
|
||||
: account
|
||||
? '在其他设备上登录这个账号,昵称和头像都一样。'
|
||||
: registrationOpen
|
||||
? '注册后可以在其他设备上登录,昵称和头像会保留。'
|
||||
: '已有账号的话,登录即可。'
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-copy">
|
||||
<p className="settings-row-title text-ui-base">{account ? account.username : '游客'}</p>
|
||||
<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 [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 {
|
||||
const { memberToken } = await api.login({ username, password })
|
||||
switchIdentity(memberToken)
|
||||
}
|
||||
}
|
||||
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>
|
||||
<p className="text-ui-caption muted">
|
||||
{register
|
||||
? '用户名 3–32 位,可用小写字母、数字、_ 和 -;密码至少 8 位。忘记密码需联系管理员。'
|
||||
: '登录后页面会重新加载。'}
|
||||
</p>
|
||||
<button type="submit" className="button-primary" disabled={busy}>
|
||||
{busy ? (register ? '正在注册…' : '正在登录…') : (register ? '注册' : '登录')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = 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>
|
||||
<p className="text-ui-caption muted">修改后,其他设备需要重新登录。</p>
|
||||
<button type="submit" className="button-primary" disabled={busy}>{busy ? '正在修改…' : '修改密码'}</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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 reload: ReturnType<typeof vi.fn>
|
||||
let me: unknown
|
||||
let updates: unknown[]
|
||||
|
||||
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 = []
|
||||
vi.stubGlobal('localStorage', local)
|
||||
vi.stubGlobal('sessionStorage', new FakeStorage())
|
||||
vi.stubGlobal('window', { location: { reload } })
|
||||
vi.stubGlobal('fetch', (path: string, init: RequestInit) => {
|
||||
const request = `${init.method} ${path}`
|
||||
if (request === 'PATCH /api/me') {
|
||||
updates.push(JSON.parse(String(init.body)))
|
||||
return Promise.resolve(new Response(JSON.stringify({ member }), { status: 200 }))
|
||||
}
|
||||
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 () => {
|
||||
local.setItem(memberTokenKey, 'account')
|
||||
local.setItem('alisten.ticket.r1', 't1')
|
||||
local.setItem(emailKey, 'alice@example.com')
|
||||
const { switchIdentity } = await load()
|
||||
switchIdentity(null)
|
||||
|
||||
expect(local.getItem(memberTokenKey)).toBeNull()
|
||||
expect(local.getItem('alisten.ticket.r1')).toBeNull()
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,18 @@
|
||||
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, deleteAllTickets, deleteMemberToken, 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 {
|
||||
@@ -27,18 +27,33 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
|
||||
return this.#loading
|
||||
}
|
||||
|
||||
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册。
|
||||
async registered(account: Account): Promise<void> {
|
||||
this.setState({ account })
|
||||
const email = localStorage.getItem(emailKey)
|
||||
if (email && await this.save({ email }))
|
||||
localStorage.removeItem(emailKey)
|
||||
}
|
||||
|
||||
// 返回 false 时,失败原因已经通过通知显示。
|
||||
async save(changes: { name?: string, email?: string }): 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 })
|
||||
const { account } = this.state
|
||||
if (account) {
|
||||
const email = changes.email?.trim() ?? this.state.email
|
||||
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 true
|
||||
}
|
||||
catch (error) {
|
||||
// 输入框限制了昵称和邮箱的长度,INVALID_ARGUMENT 只剩去掉首尾空白之后为空的昵称。
|
||||
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空,也不能只有空格。' }))
|
||||
// 客户端不能按服务端的 message 区分是哪个字段不合格。
|
||||
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -46,7 +61,8 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
|
||||
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 })
|
||||
}
|
||||
catch (error) {
|
||||
// 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。
|
||||
@@ -62,10 +78,10 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
|
||||
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 +92,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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user