feat: sign in with GitHub and manage the GitHub link in settings

This commit is contained in:
DiheChen committed 2026-11-10 13:04:09 +08:00
1 parent dd18ea7e2c
commit 6b9e3a0a24
18 files changed
+623 -49

No files matched your search

+1 -1
View File
@@ -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;
}
+9 -1
View File
@@ -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 <PairPage code={route.code} onSignedIn={() => navigationStore.replace({ name: 'lobby', filter: '', panel: null })} />
}
function RoutedGitHubLogin() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'github-login')
return null
return <GitHubLoginPage fragment={route.fragment} onDone={() => 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' && <SettingsPage />}
{page === 'shared-playlist' && <RoutedSharedPlaylist />}
{page === 'pair' && <RoutedPair />}
{page === 'github-login' && <RoutedGitHubLogin />}
</Suspense>
</ErrorBoundary>
</main>
+8
View File
@@ -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', () => {
+6
View File
@@ -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<Location, 'pathname'
return { name: 'playlists', panel }
if (pathname === '/pair')
return { name: 'pair', code: hash.slice(1), panel }
if (pathname === '/auth/github')
return { name: 'github-login', fragment: hash.slice(1), panel }
const own = /^\/playlists\/([^/]+)$/.exec(pathname)
if (own)
return { name: 'my-playlist', id: own[1], panel }
@@ -118,6 +122,8 @@ function pathOf(route: Route): string {
return `/playlists/${route.id}`
case 'pair':
return '/pair'
case 'github-login':
return '/auth/github'
case 'listen':
return `/r/${route.ref}`
case 'playlist':
+3 -1
View File
@@ -4,6 +4,7 @@ import { brandStore } from '@/core/brand'
import { useStore } from '@/shared/lib/use-store'
import { Accounts } from './accounts'
import { adminStore } from './admin-store'
import { GithubApp } from './github-app'
import { PersistentRooms } from './persistent-rooms'
import { SiteSettings } from './site-settings'
import { SourceAccounts } from './source-accounts'
@@ -39,7 +40,7 @@ export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) {
<div className="page-title">
<span className="text-ui-sm accent">{brandName}</span>
<h1 className="text-ui-xl">后台</h1>
<p className="text-ui-base muted">管理品牌名、注册、用户账号、常驻房间、音源账号和已存储的媒体。</p>
<p className="text-ui-base muted">管理品牌名、注册、GitHub 登录、用户账号、常驻房间、音源账号和已存储的媒体。</p>
</div>
{signedIn && (
<button type="button" className="button-ghost" onClick={() => adminStore.signOut()}>
@@ -52,6 +53,7 @@ export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) {
? (
<>
<SiteSettings />
<GithubApp />
<Accounts />
<PersistentRooms onEntered={onEntered} />
<SourceAccounts />
+3 -2
View File
@@ -12,7 +12,8 @@
.admin-row-title { font-weight: 600; }
.admin-row-meta { display: flex; align-items: center; gap: 6px; min-width: 0; }
.admin-row-actions { display: flex; align-items: center; gap: 8px; }
.admin-brand-name { width: 240px; }
.admin-brand-name, .admin-github-field { width: 240px; }
.admin-github-callback { overflow-wrap: anywhere; }
.admin-link { color: var(--color-foreground-subtle); }
.admin-link:hover { color: var(--color-brand); }
.admin-empty { padding: 8px 0; }
@@ -31,5 +32,5 @@
@container admin (max-width: 600px) {
.admin-row { flex-wrap: wrap; }
.admin-row-actions { flex-basis: 100%; }
.admin-brand-name { flex: 1; width: auto; }
.admin-brand-name, .admin-github-field { flex: 1; width: auto; }
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { githubAppDraft } from './github-app-draft'
describe('githubAppDraft', () => {
it('去掉从 GitHub 复制时带上的首尾空白', () => {
expect(githubAppDraft(' Ov23liExample0000001\n', '\t0123456789abcdef ')).toEqual({ clientId: 'Ov23liExample0000001', clientSecret: '0123456789abcdef' })
})
it('client ID 和 client secret 缺一个都不能保存', () => {
expect(githubAppDraft('Ov23liExample0000001', '')).toBeNull()
expect(githubAppDraft('Ov23liExample0000001', ' ')).toBeNull()
expect(githubAppDraft('', '0123456789abcdef')).toBeNull()
})
})
@@ -0,0 +1,7 @@
import type { AdminSetGithubCredentialRequest } from '@/core/api'
// 从 GitHub 复制时容易带上首尾空白,服务端不接受空白。
export function githubAppDraft(clientId: string, clientSecret: string): AdminSetGithubCredentialRequest | null {
const draft = { clientId: clientId.trim(), clientSecret: clientSecret.trim() }
return draft.clientId !== '' && draft.clientSecret !== '' ? draft : null
}
+149
View File
@@ -0,0 +1,149 @@
import type { AdminGetGithubCredentialResponse } from '@/core/api'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { ConfirmDialog, notificationStore, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import { githubAppDraft } from './github-app-draft'
async function copyCallbackUrl(url: string): Promise<void> {
try {
await navigator.clipboard.writeText(url)
notificationStore.success('已复制回调地址')
}
catch {
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权。
notificationStore.info(`请手动复制回调地址:${url}`, 8000)
}
}
export function GithubApp() {
// undefined 表示正在获取,null 表示获取失败。
const [app, setApp] = useState<AdminGetGithubCredentialResponse | null>()
const [attempt, setAttempt] = useState(0)
// null 表示沿用已保存的 Client ID。
const [clientId, setClientId] = useState<string | null>(null)
// 服务端不返回已保存的 Client Secret,而它必须和 Client ID 一起提交,所以每次保存都要重新填写。
const [clientSecret, setClientSecret] = useState('')
const [busy, setBusy] = useState(false)
const [closing, setClosing] = useState(false)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminGetGithubCredential()).then((result) => {
if (active)
setApp(result)
})
return () => {
active = false
}
}, [attempt])
const refetch = () => {
setApp(undefined)
setClientId(null)
setClientSecret('')
setAttempt(n => n + 1)
}
const draft = githubAppDraft(clientId ?? app?.clientId ?? '', clientSecret)
const save = async () => {
if (draft === null || !app)
return
setBusy(true)
try {
const saved = await adminStore.call(() => api.adminSetGithubCredential(draft), { INVALID_ARGUMENT: 'Client ID 和 Client Secret 只能包含字母、数字、点、下划线和连字符,请从 GitHub 重新复制。' })
if (saved === null)
return
refetch()
notificationStore.success(app.clientId === null ? '已开启 GitHub 登录' : '已保存 GitHub OAuth App')
}
finally {
setBusy(false)
}
}
return (
<section className="admin-section" aria-labelledby="admin-github-title">
<div className="section-heading">
<h2 id="admin-github-title" className="text-ui-lg">GitHub 登录</h2>
</div>
{app === null
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取 GitHub 登录的配置。</p>
<button type="button" className="button-secondary" onClick={refetch}>重试</button>
</div>
)
: app === undefined
? <Skeleton rows={3} />
: (
<form
className="admin-list"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">{app.clientId === null ? '未开启' : '已开启'}</span>
<span className="text-ui-sm muted">
{app.clientId === null
? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。'
: '用户可以用 GitHub 登录、注册和绑定账号。'}
</span>
</div>
{app.clientId !== null && (
<div className="admin-row-actions">
<button type="button" className="button-secondary" onClick={() => setClosing(true)}>关闭 GitHub 登录</button>
</div>
)}
</div>
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">回调地址</span>
<code className="admin-github-callback text-ui-sm">{app.callbackUrl}</code>
<span className="text-ui-sm muted">OAuth App 的 Authorization callback URL 填这个地址。</span>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" onClick={() => void copyCallbackUrl(app.callbackUrl)}>复制</button>
</div>
</div>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-github-client-id" className="admin-row-title text-ui-base">Client ID</label>
</div>
<div className="admin-row-actions">
<input id="admin-github-client-id" className="admin-github-field" autoComplete="off" spellCheck={false} value={clientId ?? app.clientId ?? ''} onChange={event => setClientId(event.target.value)} />
</div>
</div>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-github-client-secret" className="admin-row-title text-ui-base">Client Secret</label>
<span id="admin-github-client-secret-description" className="text-ui-sm muted">
{app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'}
</span>
</div>
<div className="admin-row-actions">
<input id="admin-github-client-secret" className="admin-github-field" type="password" autoComplete="off" spellCheck={false} aria-describedby="admin-github-client-secret-description" placeholder={app.clientSecretSet ? '已设置' : undefined} value={clientSecret} onChange={event => setClientSecret(event.target.value)} />
<button type="submit" className="button-secondary" disabled={busy || draft === null}>{busy ? '正在保存…' : app.clientId === null ? '开启' : '保存'}</button>
</div>
</div>
</form>
)}
{closing && (
<ConfirmDialog
title="关闭 GitHub 登录"
confirmLabel="关闭"
danger
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteGithubCredential()) === null)
return
setClosing(false)
refetch()
notificationStore.success('已关闭 GitHub 登录')
}}
onClose={() => setClosing(false)}
>
关闭后,不能再用 GitHub 登录、注册或绑定,已经绑定的 GitHub 保持绑定。只绑定了 GitHub 的账号,关闭之后就无法用 GitHub 登录,重新开启之前也无法在其他设备上登录。
</ConfirmDialog>
)}
</section>
)
}
@@ -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')
@@ -318,15 +318,17 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
#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()
}
+138 -39
View File
@@ -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 {
void run()
}
// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。
async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise<boolean> {
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<Dialog>(null)
const [site, setSite] = useState({ registrationOpen: false, githubLogin: false })
const [dialog, setDialog] = useState<Dialog>(() => 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 (
<>
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">{account ? account.username ?? '用 GitHub 登录的账号' : '游客'}</p>
{description && <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('pairing')}>在新设备上登录</button>
{account.username
? <button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
: <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('verify')}>设置用户名和密码</button>}
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
</>
)
: (
<>
{site.registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
{site.githubLogin && <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('login')}>用 GitHub 登录</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('redeem')}>输入配对码</button>
</>
)}
</div>
)}
{(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
{dialog === 'pairing' && <PairingDialog onClose={() => setDialog(null)} />}
{dialog === 'redeem' && (
<Modal title="输入配对码" onClose={() => setDialog(null)}>
<PairingCodeForm onSignedIn={switchIdentity} />
</Modal>
)}
</div>
{account && site.githubLogin && <GitHubRow account={account} />}
</>
)
}
function GitHubRow({ account }: { account: Account }) {
const [dialog, setDialog] = useState<'bind' | 'unbind' | null>(null)
const unbind = async (currentPassword: string): Promise<boolean> => {
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 (
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">{account ? account.username : '游客'}</p>
{description && <p className="text-ui-caption muted">{description}</p>}
<p className="settings-row-title text-ui-base">GitHub</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('pairing')}>在新设备上登录</button>
<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>
<button type="button" className="button-secondary" onClick={() => setDialog('redeem')}>输入配对码</button>
</>
)}
</div>
)}
{(dialog === 'register' || dialog === 'login') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
{dialog === 'pairing' && <PairingDialog onClose={() => setDialog(null)} />}
{dialog === 'redeem' && (
<Modal title="输入配对码" onClose={() => setDialog(null)}>
<PairingCodeForm onSignedIn={switchIdentity} />
</Modal>
<div className="settings-row-control settings-account-actions">
{account.github
? <button type="button" className="button-secondary" disabled={!account.username} onClick={() => setDialog('unbind')}>解绑</button>
: <button type="button" className="button-secondary" onClick={() => setDialog('bind')}>绑定 GitHub</button>}
</div>
{dialog && (
<CurrentPasswordDialog
title={dialog === 'bind' ? '绑定 GitHub' : '解绑 GitHub'}
action={dialog === 'bind' ? '去 GitHub 授权' : '解绑'}
onConfirm={dialog === 'bind' ? currentPassword => goToGitHub('bind', currentPassword) : unbind}
onClose={() => setDialog(null)}
/>
)}
</div>
)
}
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<boolean>, onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
if (!await onConfirm(currentPassword))
setBusy(false)
}
return (
<Modal title={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>
<button type="submit" className="button-primary" disabled={busy}>{action}</button>
</form>
</Modal>
)
}
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 (
<Modal title={register ? '注册账号' : '登录'} onClose={onClose}>
<Modal title={{ register: '注册账号', login: '登录', credentials: '设置用户名和密码' }[mode]} onClose={onClose}>
<form className="form-stack" onSubmit={event => submitted(event, submit)}>
<label>
用户名
@@ -120,8 +218,9 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login', onCl
? '用户名 3–32 位,可用小写字母、数字、_ 和 -;密码至少 8 位。忘记密码需联系管理员。'
: '登录后页面会重新加载。'}
</p>
{mode === 'credentials' && <p className="text-ui-caption muted">设置之后,其他设备需要重新登录。</p>}
<button type="submit" className="button-primary" disabled={busy || (register && repeated !== password)}>
{busy ? (register ? '正在注册…' : '正在登录…') : (register ? '注册' : '登录')}
{{ register: busy ? '正在注册…' : '注册', login: busy ? '正在登录…' : '登录', credentials: busy ? '正在保存…' : '保存' }[mode]}
</button>
</form>
</Modal>
@@ -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 (
<div className="page-content settings-pair-page">
<div className="page-title">
<h1 className="text-ui-xl">GitHub 登录</h1>
<p className="text-ui-base muted" role="status">{failure ?? '正在完成 GitHub 登录…'}</p>
</div>
{failure && <button type="button" className="button-primary" onClick={onDone}>回到设置</button>}
</div>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
class FakeStorage {
readonly #items = new Map<string, string>()
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' } }])
})
})
+82
View File
@@ -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<string, string> = {
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<string> {
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<FinishGithubLoginResponse> } | null = null
let credentialsPending = false
// 兑换码只能用一次:同一个兑换码重复调用时返回同一次请求的结果。
export function finishGithubLogin(code: string, nonce: string, intent: GitHubIntent): Promise<FinishGithubLoginResponse> {
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
}
+1
View File
@@ -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))
@@ -44,6 +44,11 @@ class ProfileStore extends Store<ProfileState> {
await this.#moveLocalEmail()
}
// 账号的绑定变了,成员和邮箱都不变。
updateAccount(account: Account): void {
this.setState({ account })
}
// 返回 false 时,失败原因已经通过通知显示。
async logout(): Promise<boolean> {
try {
+1 -1
View File
@@ -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'],
},
}),