From 3bc1d6e82e7ca53db99a684f0f9b46dd0d7e0b67 Mon Sep 17 00:00:00 2001
From: DiheChen
Date: Wed, 11 Nov 2026 00:17:12 +0800
Subject: [PATCH] feat: sign in with GitHub and manage the GitHub link in
settings
---
web/nginx/default.conf | 2 +-
web/src/app/app.tsx | 10 +-
web/src/app/route.test.ts | 8 +
web/src/app/route.ts | 6 +
web/src/features/admin/admin-page.tsx | 4 +-
web/src/features/admin/admin.css | 5 +-
.../features/admin/github-app-draft.test.ts | 14 ++
web/src/features/admin/github-app-draft.ts | 7 +
web/src/features/admin/github-app.tsx | 149 +++++++++++++++
.../playlists/playlists-store.test.ts | 11 ++
web/src/features/playlists/playlists-store.ts | 10 +-
web/src/features/settings/account-section.tsx | 177 ++++++++++++++----
.../features/settings/github-login-page.tsx | 52 +++++
web/src/features/settings/github.test.ts | 127 +++++++++++++
web/src/features/settings/github.ts | 82 ++++++++
web/src/features/settings/index.ts | 1 +
web/src/features/settings/profile-store.ts | 5 +
web/vite.config.ts | 2 +-
18 files changed, 623 insertions(+), 49 deletions(-)
create mode 100644 web/src/features/admin/github-app-draft.test.ts
create mode 100644 web/src/features/admin/github-app-draft.ts
create mode 100644 web/src/features/admin/github-app.tsx
create mode 100644 web/src/features/settings/github-login-page.tsx
create mode 100644 web/src/features/settings/github.test.ts
create mode 100644 web/src/features/settings/github.ts
diff --git a/web/nginx/default.conf b/web/nginx/default.conf
index d1200f2..76cd177 100644
--- a/web/nginx/default.conf
+++ b/web/nginx/default.conf
@@ -34,7 +34,7 @@ server {
add_header Cache-Control "no-cache";
try_files /index.html =404;
}
- location ~ ^/pair$ {
+ location ~ ^/(?:pair|auth/github)$ {
add_header Cache-Control "no-cache";
try_files /index.html =404;
}
diff --git a/web/src/app/app.tsx b/web/src/app/app.tsx
index 239f9c4..032b11c 100644
--- a/web/src/app/app.tsx
+++ b/web/src/app/app.tsx
@@ -12,7 +12,7 @@ import { MyPlaylistPage, PlaylistsPage, SharedPlaylistPage } from '@/features/pl
import { QueuePage, voteSkip } from '@/features/queue'
import { copyInviteLink, EntryDialog, Lobby, roomEntryStore, RoomGate } from '@/features/rooms'
import { HeaderSearch, PlaylistPage, SearchPage, searchStore } from '@/features/search'
-import { PairPage, profileStore, SettingsPage } from '@/features/settings'
+import { GitHubLoginPage, PairPage, profileStore, SettingsPage } from '@/features/settings'
import { useStore } from '@/shared/lib/use-store'
import { Avatar, IconButton, lazyPage, Notifications, notificationStore, Skeleton } from '@/shared/ui'
import { DetailPanel } from './detail-panel'
@@ -209,6 +209,13 @@ function RoutedPair() {
return navigationStore.replace({ name: 'lobby', filter: '', panel: null })} />
}
+function RoutedGitHubLogin() {
+ const route = useStore(navigationStore, s => s.route)
+ if (route.name !== 'github-login')
+ return null
+ return navigationStore.replace({ name: 'settings', panel: null })} />
+}
+
function RoutedSearch() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'search')
@@ -297,6 +304,7 @@ export function App() {
{page === 'settings' && }
{page === 'shared-playlist' && }
{page === 'pair' && }
+ {page === 'github-login' && }
diff --git a/web/src/app/route.test.ts b/web/src/app/route.test.ts
index 877f275..5034bf1 100644
--- a/web/src/app/route.test.ts
+++ b/web/src/app/route.test.ts
@@ -61,6 +61,14 @@ describe('parseRoute', () => {
expect(route && formatRoute(route)).toBe('/pair?panel=chat')
expect(parse('/pair')).toEqual({ name: 'pair', code: '', panel: null })
})
+
+ it('github 登录的结果从片段中读出,写回的地址不再带它', () => {
+ const route = parse('/auth/github#code=abc')
+ expect(route).toEqual({ name: 'github-login', fragment: 'code=abc', panel: null })
+ expect(route && formatRoute(route)).toBe('/auth/github')
+ expect(parse('/auth/github#error=denied')).toEqual({ name: 'github-login', fragment: 'error=denied', panel: null })
+ expect(parse('/auth/github/x')).toBeNull()
+ })
})
describe('isNewEntry', () => {
diff --git a/web/src/app/route.ts b/web/src/app/route.ts
index a468b95..438d984 100644
--- a/web/src/app/route.ts
+++ b/web/src/app/route.ts
@@ -20,6 +20,8 @@ export type Route
| { name: 'shared-playlist', shareId: string, panel: Panel | null }
// 配对码在片段里,不进入请求和 Referer;写回地址时去掉,不留在历史记录中。
| { name: 'pair', code: string, panel: Panel | null }
+ // GitHub 授权之后的落地页,片段里是 code=<兑换码> 或 error=<错误码>,同样不留在地址中。
+ | { name: 'github-login', fragment: string, panel: Panel | null }
export type Page = Route['name']
@@ -77,6 +79,8 @@ export function parseRoute({ pathname, search, hash }: Pick void }) {
{brandName}
后台
-
管理品牌名、注册、用户账号、常驻房间、音源账号和已存储的媒体。
+
管理品牌名、注册、GitHub 登录、用户账号、常驻房间、音源账号和已存储的媒体。
{signedIn && (
+ {app === null
+ ? (
+
+
暂时无法获取 GitHub 登录的配置。
+
+
+ )
+ : app === undefined
+ ?
+ : (
+
+ )}
+ {closing && (
+ {
+ if (await adminStore.call(() => api.adminDeleteGithubCredential()) === null)
+ return
+ setClosing(false)
+ refetch()
+ notificationStore.success('已关闭 GitHub 登录')
+ }}
+ onClose={() => setClosing(false)}
+ >
+ 关闭后,不能再用 GitHub 登录、注册或绑定,已经绑定的 GitHub 保持绑定。只绑定了 GitHub 的账号,关闭之后就无法用 GitHub 登录,重新开启之前也无法在其他设备上登录。
+
+ )}
+
+ )
+}
diff --git a/web/src/features/playlists/playlists-store.test.ts b/web/src/features/playlists/playlists-store.test.ts
index a548ac9..d8257d3 100644
--- a/web/src/features/playlists/playlists-store.test.ts
+++ b/web/src/features/playlists/playlists-store.test.ts
@@ -103,6 +103,8 @@ beforeEach(() => {
requests.push(request)
if (request === 'GET /api/me/playlists')
return read({ items: [...serverPlaylists] })
+ if (request === 'GET /api/me')
+ return json(200, { member: { id: 'm1', name: 'octocat', avatarHash: '' }, account: { github: true } })
if (request === 'POST /api/me/playlist-memberships') {
const { track } = JSON.parse(String(init.body)) as { track: Track }
return read({ playlistIds: [...server].filter(([, tracks]) => tracks.has(key(track))).map(([id]) => id) })
@@ -472,6 +474,15 @@ describe('playlistsStore', () => {
expect(requests.filter(r => r === 'GET /api/me/playlists')).toHaveLength(2)
})
+ it('只绑定了 GitHub 的账号没有用户名,同样读取歌单', async () => {
+ const { playlistsStore } = await import('./playlists-store')
+ const { profileStore } = await import('@/features/settings')
+ playlistsStore.start()
+ await profileStore.load()
+ await flush()
+ expect(playlistsStore.state.playlists).toEqual([favorites])
+ })
+
it('换账号之后,上一个账号的读取结果作废', async () => {
const { playlistsStore } = await import('./playlists-store')
const { profileStore } = await import('@/features/settings')
diff --git a/web/src/features/playlists/playlists-store.ts b/web/src/features/playlists/playlists-store.ts
index d745f96..263b2cc 100644
--- a/web/src/features/playlists/playlists-store.ts
+++ b/web/src/features/playlists/playlists-store.ts
@@ -318,15 +318,17 @@ class PlaylistsStore extends Store {
}
#onProfile = (): void => {
- const username = profileStore.state.account?.username ?? null
- if (username === this.#account)
+ const { member, account } = profileStore.state
+ // 只绑定了 GitHub 的账号没有用户名,用成员 ID 区分。
+ const owner = account ? account.username ?? member?.id ?? null : null
+ if (owner === this.#account)
return
- this.#account = username
+ this.#account = owner
this.#generation++
this.#membershipReads.clear()
this.#syncing.clear()
this.setState(empty)
- if (username)
+ if (owner)
void this.load()
}
diff --git a/web/src/features/settings/account-section.tsx b/web/src/features/settings/account-section.tsx
index 5e393fd..0a51476 100644
--- a/web/src/features/settings/account-section.tsx
+++ b/web/src/features/settings/account-section.tsx
@@ -1,14 +1,17 @@
import type { FormEvent } from 'react'
+import type { Account, GitHubIntent } from '@/core/realtime'
import { useEffect, useId, useState } from 'react'
-import { api, IdentityRenewedError } from '@/core/api'
+import { api, ApiError, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { useStore } from '@/shared/lib/use-store'
import { Modal, notificationStore } from '@/shared/ui'
+import { startGithubLogin, takeCredentialsPrompt } from './github'
import { PairingCodeForm, PairingDialog } from './pairing-dialog'
import { loginWithPassword } from './password-login'
import { profileStore, switchIdentity } from './profile-store'
-type Dialog = 'register' | 'login' | 'password' | 'pairing' | 'redeem' | null
+// credentials:只绑定了 GitHub 的账号补设用户名和密码,用的是注册接口。
+type Dialog = 'register' | 'login' | 'credentials' | 'password' | 'pairing' | 'redeem' | null
const credentialTexts = { INVALID_ARGUMENT: '用户名要 3–32 位小写字母、数字、_ 或 -,密码要 8–128 位。' }
@@ -19,18 +22,30 @@ function submitted(event: FormEvent, run: () => Promise): void {
void run()
}
+// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。
+async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise {
+ try {
+ window.location.assign(await startGithubLogin(intent, currentPassword))
+ return true
+ }
+ catch (error) {
+ notificationStore.error(error instanceof IdentityRenewedError ? '没有跳转到 GitHub,再试一次。' : errorMessage(error))
+ return false
+ }
+}
+
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
+ {mode === 'credentials' && 设置之后,其他设备需要重新登录。
}
diff --git a/web/src/features/settings/github-login-page.tsx b/web/src/features/settings/github-login-page.tsx
new file mode 100644
index 0000000..1ee3e25
--- /dev/null
+++ b/web/src/features/settings/github-login-page.tsx
@@ -0,0 +1,52 @@
+import { useEffect, useEffectEvent, useState } from 'react'
+import { IdentityRenewedError } from '@/core/api'
+import { errorMessage } from '@/core/errors'
+import { notificationStore } from '@/shared/ui'
+import { finishGithubLogin, takeGithubLanding } from './github'
+import { profileStore, switchIdentity } from './profile-store'
+import './settings.css'
+
+// GitHub 授权之后的落地页。onDone 在离开这一页时调用(登录了另一个账号时在重新加载页面之前),由应用壳决定之后停在哪里。
+export function GitHubLoginPage({ fragment, onDone }: { fragment: string, onDone: () => void }) {
+ const [landing] = useState(() => takeGithubLanding(fragment))
+ const [failure, setFailure] = useState(landing.kind === 'failed' ? landing.message : null)
+ const done = useEffectEvent(onDone)
+ useEffect(() => {
+ if (landing.kind !== 'exchange')
+ return
+ let active = true
+ finishGithubLogin(landing.code, landing.nonce, landing.intent).then((result) => {
+ if (!active)
+ return
+ if (result.memberToken) {
+ done()
+ switchIdentity(result.memberToken)
+ return
+ }
+ // 确认身份不修改账号,回到设置页接着设置用户名和密码。
+ if (landing.intent === 'verify') {
+ done()
+ return
+ }
+ // 注册或绑定:成员和成员 token 都不变,不必重新加载。能绑定 GitHub 的只有设置了用户名的账号,注册得到的账号没有用户名。
+ void profileStore.registered(result.account)
+ notificationStore.success(result.account.username ? '已绑定 GitHub' : '已用 GitHub 注册')
+ done()
+ }, (error: unknown) => {
+ if (active)
+ setFailure(error instanceof IdentityRenewedError ? 'GitHub 登录没有完成,请回到设置重新发起。' : errorMessage(error))
+ })
+ return () => {
+ active = false
+ }
+ }, [landing])
+ return (
+
+
+
GitHub 登录
+
{failure ?? '正在完成 GitHub 登录…'}
+
+ {failure &&
}
+
+ )
+}
diff --git a/web/src/features/settings/github.test.ts b/web/src/features/settings/github.test.ts
new file mode 100644
index 0000000..4420a13
--- /dev/null
+++ b/web/src/features/settings/github.test.ts
@@ -0,0 +1,127 @@
+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)
+ }
+}
+
+let session: FakeStorage
+let requests: { request: string, body: unknown }[]
+
+beforeEach(() => {
+ vi.resetModules()
+ session = new FakeStorage()
+ requests = []
+ vi.stubGlobal('sessionStorage', session)
+ vi.stubGlobal('localStorage', new FakeStorage())
+ vi.stubGlobal('window', new EventTarget())
+ localStorage.setItem('alisten.member-token', 'token')
+ vi.stubGlobal('fetch', async (path: string, init: RequestInit) => {
+ const request = `${init.method} ${path}`
+ requests.push({ request, body: init.body ? JSON.parse(String(init.body)) : undefined })
+ if (request === 'POST /api/auth/github')
+ return new Response(JSON.stringify({ url: 'https://github.com/login/oauth/authorize?state=s' }), { status: 201 })
+ if (request === 'POST /api/auth/github/exchange')
+ return new Response(JSON.stringify({ member: { id: 'm1', name: 'octocat', avatarHash: '' }, account: { github: true } }), { status: 200 })
+ return new Response(null, { status: 404 })
+ })
+})
+
+describe('发起授权', () => {
+ it('生成随机的 nonce,存在本标签页,随请求带给服务端', async () => {
+ const { startGithubLogin } = await import('./github')
+ const url = await startGithubLogin('bind')
+ expect(url).toBe('https://github.com/login/oauth/authorize?state=s')
+ const nonce = session.getItem('alisten.github-nonce')
+ expect(nonce).toMatch(/^[\w-]{43}$/)
+ expect(requests).toEqual([{ request: 'POST /api/auth/github', body: { intent: 'bind', nonce } }])
+ await startGithubLogin('login')
+ expect(session.getItem('alisten.github-nonce')).not.toBe(nonce)
+ })
+
+ it('绑定时带上用户输入的当前密码,其他意图不带', async () => {
+ const { startGithubLogin } = await import('./github')
+ await startGithubLogin('bind', 'correct horse')
+ await startGithubLogin('verify')
+ expect(requests.map(r => r.body)).toEqual([
+ { intent: 'bind', nonce: expect.any(String), currentPassword: 'correct horse' },
+ { intent: 'verify', nonce: expect.any(String) },
+ ])
+ })
+})
+
+describe('用 GitHub 确认身份之后设置用户名和密码', () => {
+ it('意图和 nonce 一起存在本标签页,落地时一起取出', async () => {
+ const { startGithubLogin, takeGithubLanding } = await import('./github')
+ await startGithubLogin('verify')
+ const nonce = session.getItem('alisten.github-nonce')!
+ expect(takeGithubLanding('code=abc')).toEqual({ kind: 'exchange', code: 'abc', nonce, intent: 'verify' })
+ expect(session.getItem('alisten.github-intent')).toBeNull()
+ })
+
+ it('确认成功之后,设置页打开一次设置用户名和密码的对话框', async () => {
+ const { finishGithubLogin, takeCredentialsPrompt } = await import('./github')
+ expect(takeCredentialsPrompt()).toBe(false)
+ await finishGithubLogin('abc', 'n', 'verify')
+ expect(takeCredentialsPrompt()).toBe(true)
+ expect(takeCredentialsPrompt()).toBe(false)
+ })
+
+ it('登录、绑定,以及确认失败时不打开', async () => {
+ const { finishGithubLogin, takeCredentialsPrompt } = await import('./github')
+ await finishGithubLogin('login-code', 'n', 'login')
+ await finishGithubLogin('bind-code', 'n', 'bind')
+ expect(takeCredentialsPrompt()).toBe(false)
+ vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ error: { code: 'INVALID_CREDENTIALS', message: '需要用这个账号绑定的 GitHub 确认' } }), { status: 401 }))
+ await expect(finishGithubLogin('other-code', 'n', 'verify')).rejects.toThrow('需要用这个账号绑定的 GitHub 确认')
+ expect(takeCredentialsPrompt()).toBe(false)
+ })
+})
+
+describe('落地页', () => {
+ it('兑换码和本标签页保存的 nonce 一起交给兑换,nonce 取出即删除', async () => {
+ const { startGithubLogin, takeGithubLanding } = await import('./github')
+ await startGithubLogin('login')
+ const nonce = session.getItem('alisten.github-nonce')!
+ expect(takeGithubLanding('code=abc')).toEqual({ kind: 'exchange', code: 'abc', nonce, intent: 'login' })
+ expect(session.getItem('alisten.github-nonce')).toBeNull()
+ // 同一次落地重复读取(例如开发模式下组件挂载两次)得到同样的结果。
+ expect(takeGithubLanding('code=abc')).toEqual({ kind: 'exchange', code: 'abc', nonce, intent: 'login' })
+ })
+
+ it('本标签页没有发起过授权时不兑换:别人发来的落地页链接换不到会话', async () => {
+ const { takeGithubLanding } = await import('./github')
+ expect(takeGithubLanding('code=abc')).toEqual({ kind: 'failed', message: '这次 GitHub 登录不是在这个页面发起的,请回到设置重新发起。' })
+ })
+
+ it.each([
+ ['error=denied', '你在 GitHub 上取消了授权。'],
+ ['error=expired', 'GitHub 授权已经过期,请回到设置重新发起。'],
+ ['error=failed', '没有从 GitHub 确认你的身份,请稍后再试。'],
+ ['error=unknown', '没有从 GitHub 确认你的身份,请稍后再试。'],
+ ['', '没有收到 GitHub 的授权结果,请回到设置重新发起。'],
+ ])('%s', async (fragment, message) => {
+ const { startGithubLogin, takeGithubLanding } = await import('./github')
+ await startGithubLogin('login')
+ expect(takeGithubLanding(fragment)).toEqual({ kind: 'failed', message })
+ expect(session.getItem('alisten.github-nonce')).toBeNull()
+ })
+
+ it('同一个兑换码只兑换一次', async () => {
+ const { finishGithubLogin } = await import('./github')
+ const [first, second] = await Promise.all([finishGithubLogin('abc', 'n', 'login'), finishGithubLogin('abc', 'n', 'login')])
+ expect(first).toBe(second)
+ expect(requests).toEqual([{ request: 'POST /api/auth/github/exchange', body: { code: 'abc', nonce: 'n' } }])
+ })
+})
diff --git a/web/src/features/settings/github.ts b/web/src/features/settings/github.ts
new file mode 100644
index 0000000..324d2ed
--- /dev/null
+++ b/web/src/features/settings/github.ts
@@ -0,0 +1,82 @@
+import type { FinishGithubLoginResponse } from '@/core/api'
+import type { GitHubIntent } from '@/core/realtime'
+import { api } from '@/core/api'
+
+// GitHub 跳回来时仍在发起授权的这个标签页,所以 nonce 存在 sessionStorage:别的标签页、别的浏览器拿到兑换码也没有它。
+// 意图也存在这里,回来之后据此决定接下来做什么。
+const nonceKey = 'alisten.github-nonce'
+const intentKey = 'alisten.github-intent'
+
+const failures: Record = {
+ denied: '你在 GitHub 上取消了授权。',
+ expired: 'GitHub 授权已经过期,请回到设置重新发起。',
+}
+
+function randomNonce(): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
+ return btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
+}
+
+// 返回 GitHub 的授权地址,调用方随后跳转过去。绑定要带上用户输入的当前密码。
+export async function startGithubLogin(intent: GitHubIntent, currentPassword?: string): Promise {
+ const nonce = randomNonce()
+ sessionStorage.setItem(nonceKey, nonce)
+ sessionStorage.setItem(intentKey, intent)
+ const { url } = await api.startGithubLogin({ intent, nonce, currentPassword })
+ return url
+}
+
+export type GithubLanding
+ = | { kind: 'exchange', code: string, nonce: string, intent: GitHubIntent }
+ | { kind: 'failed', message: string }
+
+let landed: { fragment: string, landing: GithubLanding } | null = null
+
+// fragment 是落地页地址中 # 之后的部分:code=<兑换码> 或 error=<错误码>。保存的 nonce 取出即删除,每次发起只能兑换一次;
+// 同一次落地重复读取时(开发模式下组件会挂载两次)返回同样的结果。
+export function takeGithubLanding(fragment: string): GithubLanding {
+ if (landed?.fragment === fragment)
+ return landed.landing
+ const nonce = sessionStorage.getItem(nonceKey)
+ const intent = sessionStorage.getItem(intentKey) as GitHubIntent | null
+ sessionStorage.removeItem(nonceKey)
+ sessionStorage.removeItem(intentKey)
+ const params = new URLSearchParams(fragment)
+ const code = params.get('code')
+ const error = params.get('error')
+ let landing: GithubLanding
+ if (code)
+ landing = nonce && intent ? { kind: 'exchange', code, nonce, intent } : { kind: 'failed', message: '这次 GitHub 登录不是在这个页面发起的,请回到设置重新发起。' }
+ else if (error)
+ landing = { kind: 'failed', message: failures[error] ?? '没有从 GitHub 确认你的身份,请稍后再试。' }
+ else
+ landing = { kind: 'failed', message: '没有收到 GitHub 的授权结果,请回到设置重新发起。' }
+ landed = { fragment, landing }
+ return landing
+}
+
+let finishing: { code: string, result: Promise } | null = null
+let credentialsPending = false
+
+// 兑换码只能用一次:同一个兑换码重复调用时返回同一次请求的结果。
+export function finishGithubLogin(code: string, nonce: string, intent: GitHubIntent): Promise {
+ if (finishing?.code !== code) {
+ let result = api.finishGithubLogin({ code, nonce })
+ if (intent === 'verify') {
+ result = result.then((response) => {
+ credentialsPending = true
+ return response
+ })
+ }
+ finishing = { code, result }
+ }
+ return finishing.result
+}
+
+// 用 GitHub 确认了身份,接下来设置用户名和密码:落地页回到设置页,设置页打开对话框。用户名和密码不在跳转期间保存,回来之后才填写。
+// 只返回一次 true。
+export function takeCredentialsPrompt(): boolean {
+ const pending = credentialsPending
+ credentialsPending = false
+ return pending
+}
diff --git a/web/src/features/settings/index.ts b/web/src/features/settings/index.ts
index 5207123..da7d291 100644
--- a/web/src/features/settings/index.ts
+++ b/web/src/features/settings/index.ts
@@ -5,3 +5,4 @@ export { performanceStore } from './performance-store'
export { profileStore } from './profile-store'
export const SettingsPage = lazyPage(() => import('./settings-page').then(module => module.SettingsPage))
export const PairPage = lazyPage(() => import('./pair-page').then(module => module.PairPage))
+export const GitHubLoginPage = lazyPage(() => import('./github-login-page').then(module => module.GitHubLoginPage))
diff --git a/web/src/features/settings/profile-store.ts b/web/src/features/settings/profile-store.ts
index f10bd6e..23365ff 100644
--- a/web/src/features/settings/profile-store.ts
+++ b/web/src/features/settings/profile-store.ts
@@ -44,6 +44,11 @@ class ProfileStore extends Store {
await this.#moveLocalEmail()
}
+ // 账号的绑定变了,成员和邮箱都不变。
+ updateAccount(account: Account): void {
+ this.setState({ account })
+ }
+
// 返回 false 时,失败原因已经通过通知显示。
async logout(): Promise {
try {
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 03a75ca..6102f45 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -62,7 +62,7 @@ export default defineConfig({
workbox: {
// 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。
// Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。
- navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/, /^\/s\/[^/?]+(?:\?.*)?$/, /^\/pair(?:\?.*)?$/],
+ navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/, /^\/s\/[^/?]+(?:\?.*)?$/, /^\/(?:pair|auth\/github)(?:\?.*)?$/],
importScripts: ['notification-click.js'],
},
}),