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 === undefined + ? + : ( +
{ + event.preventDefault() + void save() + }} + > +
+
+ {app.clientId === null ? '未开启' : '已开启'} + + {app.clientId === null + ? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。' + : '用户可以用 GitHub 登录、注册和绑定账号。'} + +
+ {app.clientId !== null && ( +
+ +
+ )} +
+
+
+ 回调地址 + {app.callbackUrl} + OAuth App 的 Authorization callback URL 填这个地址。 +
+
+ +
+
+
+
+ +
+
+ setClientId(event.target.value)} /> +
+
+
+
+ + + {app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'} + +
+
+ setClientSecret(event.target.value)} /> + +
+
+
+ )} + {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(null) + const [site, setSite] = useState({ registrationOpen: false, githubLogin: false }) + const [dialog, setDialog] = useState(() => takeCredentialsPrompt() ? 'credentials' : null) const [busy, setBusy] = useState(false) useEffect(() => { let active = true - api.getSite().then((site) => { + api.getSite().then(({ registrationOpen, githubLogin }) => { if (active) - setRegistrationOpen(site.registrationOpen) + setSite({ registrationOpen, githubLogin }) }, () => {}) return () => { active = false @@ -41,56 +56,133 @@ export function AccountRow() { if (!await profileStore.logout()) setBusy(false) } + const github = async (intent: GitHubIntent) => { + setBusy(true) + if (!await goToGitHub(intent)) + setBusy(false) + } const description = member ? null : failed ? '暂时无法获取你的资料。' : '正在获取你的资料…' + return ( + <> +
+
+

{account ? account.username ?? '用 GitHub 登录的账号' : '游客'}

+ {description &&

{description}

} +
+ {member && ( +
+ {account + ? ( + <> + + {account.username + ? + : } + + + ) + : ( + <> + {site.registrationOpen && } + + {site.githubLogin && } + + + )} +
+ )} + {(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && setDialog(null)} />} + {dialog === 'password' && setDialog(null)} />} + {dialog === 'pairing' && setDialog(null)} />} + {dialog === 'redeem' && ( + setDialog(null)}> + + + )} +
+ {account && site.githubLogin && } + + ) +} + +function GitHubRow({ account }: { account: Account }) { + const [dialog, setDialog] = useState<'bind' | 'unbind' | null>(null) + const unbind = async (currentPassword: string): Promise => { + try { + await api.unbindGithub({ currentPassword }) + profileStore.updateAccount({ ...account, github: false }) + notificationStore.success('已解绑 GitHub') + setDialog(null) + return true + } + catch (error) { + notificationStore.error(error instanceof IdentityRenewedError ? '没有解绑,再试一次。' : errorMessage(error)) + return false + } + } + const description = !account.github + ? '绑定之后,可以用 GitHub 登录这个账号。' + : account.username + ? '已绑定,可以用 GitHub 登录这个账号。' + : '已绑定。先设置用户名和密码,才能解绑。' return (
-

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

- {description &&

{description}

} +

GitHub

+

{description}

- {member && ( -
- {account - ? ( - <> - - - - - ) - : ( - <> - {registrationOpen && } - - - - )} -
- )} - {(dialog === 'register' || dialog === 'login') && setDialog(null)} />} - {dialog === 'password' && setDialog(null)} />} - {dialog === 'pairing' && setDialog(null)} />} - {dialog === 'redeem' && ( - setDialog(null)}> - - +
+ {account.github + ? + : } +
+ {dialog && ( + goToGitHub('bind', currentPassword) : unbind} + onClose={() => setDialog(null)} + /> )}
) } -function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login', onClose: () => void }) { +// 绑定和解绑 GitHub 都会更换登录方式,先输入当前密码:只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示; +// 绑定成功时页面随即跳到 GitHub,对话框保持忙碌。 +function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise, onClose: () => void }) { + const [currentPassword, setCurrentPassword] = useState('') + const [busy, setBusy] = useState(false) + const submit = async () => { + setBusy(true) + if (!await onConfirm(currentPassword)) + setBusy(false) + } + return ( + +
submitted(event, submit)}> + + +
+
+ ) +} + +function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'credentials', onClose: () => void }) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [repeated, setRepeated] = useState('') const [busy, setBusy] = useState(false) - const register = mode === 'register' + const register = mode !== 'login' const submit = async () => { setBusy(true) try { if (register) { const { account } = await api.register({ username, password }) - notificationStore.success('已注册') + notificationStore.success(mode === 'credentials' ? '已设置用户名和密码,其他设备已退出登录' : '已注册') onClose() void profileStore.registered(account) } @@ -99,12 +191,18 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login', onCl } } catch (error) { + // 用 GitHub 确认身份已经过期:在对话框里重试也没用,关掉它,从按钮重新确认。 + if (mode === 'credentials' && error instanceof ApiError && error.code === 'FORBIDDEN') { + notificationStore.error('用 GitHub 确认身份已经超过 10 分钟,请再点一次“设置用户名和密码”。') + onClose() + return + } notificationStore.error(error instanceof IdentityRenewedError ? (register ? '没有注册成功,再试一次。' : '没有登录,再试一次。') : errorMessage(error, credentialTexts)) setBusy(false) } } return ( - +
submitted(event, submit)}>
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'], }, }),