feat: register and sign in from the settings page

This commit is contained in:
DiheChen committed 2026-11-08 22:05:21 +08:00
1 parent ebf7262cad
commit 50a51a9b05
8 files changed
+272 -8

No files matched your search

+9
View File
@@ -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('换取票据', () => {
+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')
})
+9
View File
@@ -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 -1
View File
@@ -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,183 @@
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 AccountSection() {
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) {
notificationStore.error(errorMessage(error))
setBusy(false)
}
}
const logoutOthers = async () => {
setBusy(true)
try {
await api.logoutOtherSessions()
notificationStore.success('其他设备已退出登录')
}
catch (error) {
notificationStore.error(errorMessage(error))
}
finally {
setBusy(false)
}
}
const description = !member
? (failed ? '暂时无法获取你的资料。' : '正在获取你的资料…')
: account
? '在其他设备上用这个用户名登录,就是同一个身份。'
: registrationOpen
? '注册之后可以在其他设备上登录同一个身份,昵称和头像都会保留。'
: '在其他设备上注册过账号的话,登录之后就是同一个身份。'
return (
<section className="settings-section" aria-labelledby="settings-account-title">
<h2 id="settings-account-title" className="text-ui-lg">账号</h2>
<div className="settings-group">
<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 logoutOthers()}>退出其他设备</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>
)}
</div>
</div>
{(dialog === 'register' || dialog === 'login') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
</section>
)
}
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 })
profileStore.setAccount(account)
notificationStore.success('已注册')
onClose()
}
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" 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>
)
}
+23 -7
View File
@@ -1,6 +1,6 @@
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'
@@ -8,11 +8,11 @@ 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,6 +27,10 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
return this.#loading
}
setAccount(account: Account): void {
this.setState({ account })
}
// 返回 false 时,失败原因已经通过通知显示。
async save(changes: { name?: string, email?: string }): Promise<boolean> {
try {
@@ -46,7 +50,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, failed: false })
}
catch (error) {
// 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。
@@ -62,10 +67,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 +81,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 { AccountSection } from './account-section'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { profileStore } from './profile-store'
@@ -177,6 +178,7 @@ export function SettingsPage() {
<div className="settings-page-body">
<fieldset className="settings-fields" disabled={saving}>
<div className="settings-content">
<AccountSection />
<section className="settings-section" aria-labelledby="settings-profile-title">
<h2 id="settings-profile-title" className="text-ui-lg">个人资料</h2>
<div className="settings-group">
+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; }