From 50a51a9b051d18eaddb90ec2a5ff978bab0db28c Mon Sep 17 00:00:00 2001 From: DiheChen Date: Sun, 8 Nov 2026 22:05:21 +0800 Subject: [PATCH] feat: register and sign in from the settings page --- web/src/core/api/client.test.ts | 9 + web/src/core/api/credentials.test.ts | 44 +++++ web/src/core/api/credentials.ts | 9 + web/src/core/api/index.ts | 2 +- web/src/features/settings/account-section.tsx | 183 ++++++++++++++++++ web/src/features/settings/profile-store.ts | 30 ++- web/src/features/settings/settings-page.tsx | 2 + web/src/features/settings/settings.css | 1 + 8 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 web/src/core/api/credentials.test.ts create mode 100644 web/src/features/settings/account-section.tsx diff --git a/web/src/core/api/client.test.ts b/web/src/core/api/client.test.ts index fbdcdc1..1e3d335 100644 --- a/web/src/core/api/client.test.ts +++ b/web/src/core/api/client.test.ts @@ -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('换取票据', () => { 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..edb09bd 100644 --- a/web/src/core/api/credentials.ts +++ b/web/src/core/api/credentials.ts @@ -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..6fa8388 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 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..8eb6504 --- /dev/null +++ b/web/src/features/settings/account-section.tsx @@ -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 { + 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(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 ( +
+

账号

+
+
+
+

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

+

{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 [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 ( + +
submitted(event, submit)}> + + +

+ {register + ? '用户名 3 到 32 位,可以用小写字母、数字、下划线和连字符;密码至少 8 位。忘记密码时只能请管理员重置。' + : '登录之后,这台设备上现在的游客身份不再使用,页面会重新加载。'} +

+ +
+
+ ) +} + +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 ( + +
submitted(event, submit)}> + + +

修改之后,其他设备上的登录都会失效,这台设备不受影响。

+ +
+
+ ) +} diff --git a/web/src/features/settings/profile-store.ts b/web/src/features/settings/profile-store.ts index 324773d..6a5709d 100644 --- a/web/src/features/settings/profile-store.ts +++ b/web/src/features/settings/profile-store.ts @@ -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 | 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 { try { @@ -46,7 +50,8 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed: 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, 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() +} diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx index ac84d20..57ebc7c 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 { 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() {
+

个人资料

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; }