feat(frontend): login with center URL and consume center-issued guild tokens

This commit is contained in:
nav
2026-05-13 08:00:23 +00:00
parent 66c49ff654
commit c906cde209
10 changed files with 170 additions and 125 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.gitignore
Dockerfile
*.log
.env*

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

17
docker/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /healthz {
access_log off;
return 200 'ok';
add_header Content-Type text/plain;
}
}

View File

@@ -13,15 +13,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
() => ({ () => ({
session, session,
isAuthed: !!session, isAuthed: !!session,
login: async (email: string, password: string) => { login: async (centerApiBase: string, email: string, password: string) => {
const next = await loginCenter({ email, password }) const next = await loginCenter(centerApiBase, { email, password })
setAuthSession(next) setAuthSession(next)
setSession(next) setSession(next)
}, },
logout: async () => { logout: async () => {
if (session?.refreshToken) { if (session?.refreshToken) {
try { try {
await logoutCenter(session.refreshToken) await logoutCenter(session.centerApiBase, session.refreshToken)
} catch { } catch {
// noop // noop
} }
@@ -33,7 +33,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
if (!session) return null if (!session) return null
if (!isAccessTokenStale(session.accessToken)) return session.accessToken if (!isAccessTokenStale(session.accessToken)) return session.accessToken
const refreshed = await refreshCenter(session.refreshToken) const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
const next: AuthSession = { const next: AuthSession = {
...session, ...session,
accessToken: refreshed.accessToken, accessToken: refreshed.accessToken,

View File

@@ -4,7 +4,7 @@ import type { AuthSession } from '../lib/auth-storage'
export type AuthContextValue = { export type AuthContextValue = {
session: AuthSession | null session: AuthSession | null
isAuthed: boolean isAuthed: boolean
login: (email: string, password: string) => Promise<void> login: (centerApiBase: string, email: string, password: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
ensureFreshToken: () => Promise<string | null> ensureFreshToken: () => Promise<string | null>
} }

View File

@@ -1,4 +1,5 @@
export type AuthSession = { export type AuthSession = {
centerApiBase: string
accessToken: string accessToken: string
refreshToken: string refreshToken: string
tokenType: string tokenType: string
@@ -6,6 +7,17 @@ export type AuthSession = {
id: string id: string
email: string email: string
} }
guilds: Array<{
nodeId: string
name: string
endpoint: string
status: 'active' | 'offline' | 'revoked'
}>
guildAccessTokens: Array<{
guildNodeId: string
token: string
tokenType: string
}>
} }
const KEY = 'fabric.auth.session.v1' const KEY = 'fabric.auth.session.v1'

View File

@@ -1,14 +1,16 @@
import axios from 'axios' import axios from 'axios'
import { getRuntimeConfig } from './runtime-config'
import type { AuthSession } from './auth-storage' import type { AuthSession } from './auth-storage'
export type LoginPayload = { email: string; password: string } export type LoginPayload = { email: string; password: string }
type LoginResponse = { type LoginResponse = {
centerApiBase: string
accessToken: string accessToken: string
refreshToken: string refreshToken: string
tokenType: string tokenType: string
user: { id: string; email: string } user: { id: string; email: string }
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string }>
} }
type RefreshResponse = { type RefreshResponse = {
@@ -17,17 +19,14 @@ type RefreshResponse = {
tokenType: string tokenType: string
} }
function centerClient() { function centerClient(centerApiBase: string) {
const cfg = getRuntimeConfig()
const client = axios.create({ const client = axios.create({
baseURL: cfg.centerApiBase, baseURL: centerApiBase,
timeout: 10000, timeout: 10000,
}) })
client.interceptors.request.use((request) => { client.interceptors.request.use((request) => {
const { apiKey } = getRuntimeConfig()
const requestId = crypto.randomUUID() const requestId = crypto.randomUUID()
if (apiKey) request.headers['x-api-key'] = apiKey
request.headers['x-request-id'] = requestId request.headers['x-request-id'] = requestId
request.headers['x-client-name'] = 'fabric-frontend' request.headers['x-client-name'] = 'fabric-frontend'
return request return request
@@ -36,16 +35,16 @@ function centerClient() {
return client return client
} }
export async function loginCenter(payload: LoginPayload): Promise<AuthSession> { export async function loginCenter(centerApiBase: string, payload: LoginPayload): Promise<AuthSession> {
const res = await centerClient().post<LoginResponse>('/auth/login', payload) const res = await centerClient(centerApiBase).post<LoginResponse>('/auth/login', payload)
return { ...res.data, centerApiBase }
}
export async function refreshCenter(centerApiBase: string, refreshToken: string): Promise<RefreshResponse> {
const res = await centerClient(centerApiBase).post<RefreshResponse>('/auth/refresh', { refreshToken })
return res.data return res.data
} }
export async function refreshCenter(refreshToken: string): Promise<RefreshResponse> { export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> {
const res = await centerClient().post<RefreshResponse>('/auth/refresh', { refreshToken }) await centerClient(centerApiBase).post('/auth/logout', { refreshToken })
return res.data
}
export async function logoutCenter(refreshToken: string): Promise<void> {
await centerClient().post('/auth/logout', { refreshToken })
} }

View File

@@ -1,7 +1,8 @@
import axios from 'axios'
import { io, type Socket } from 'socket.io-client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { getApiClient } from '../lib/api-client' import { useAuth } from '../auth/auth-context'
import { disconnectSocket, getSocketClient } from '../lib/socket-client'
type MessageItem = { type MessageItem = {
messageId: string messageId: string
@@ -17,6 +18,7 @@ type PageInfo = {
} }
export default function ChatPage() { export default function ChatPage() {
const { session } = useAuth()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const guildId = searchParams.get('guildId') ?? '' const guildId = searchParams.get('guildId') ?? ''
const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '') const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '')
@@ -34,14 +36,42 @@ export default function ChatPage() {
const [editingMessageId, setEditingMessageId] = useState('') const [editingMessageId, setEditingMessageId] = useState('')
const [editingContent, setEditingContent] = useState('') const [editingContent, setEditingContent] = useState('')
const socket = useMemo(() => getSocketClient('frontend-user'), []) const guild = useMemo(() => (session?.guilds ?? []).find((g) => g.nodeId === guildId) ?? null, [session, guildId])
const guildToken = useMemo(
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === guildId)?.token ?? '',
[session, guildId],
)
function guildApi() {
if (!guild || !guildToken) throw new Error('guild or token missing')
return axios.create({
baseURL: `${guild.endpoint}/api`,
timeout: 10000,
headers: {
Authorization: `Bearer ${guildToken}`,
},
})
}
const socket = useMemo<Socket | null>(() => {
if (!guild || !guildToken) return null
return io(`${guild.endpoint}/realtime`, {
transports: ['websocket'],
auth: {
token: guildToken,
},
autoConnect: false,
})
}, [guild, guildToken])
useEffect(() => { useEffect(() => {
if (!socket) return
socket.on('connect', () => { socket.on('connect', () => {
setSocketState('online') setSocketState('online')
if (!channelId) return if (!channelId) return
socket.emit('join_channel', { channelId }) socket.emit('join_channel', { channelId })
void getApiClient() void guildApi()
.get(`/channels/${channelId}/messages`, { .get(`/channels/${channelId}/messages`, {
params: { params: {
seq_from: Number(seqFrom || '1'), seq_from: Number(seqFrom || '1'),
@@ -55,14 +85,10 @@ export default function ChatPage() {
}) })
}) })
socket.on('disconnect', () => setSocketState('offline')) socket.on('disconnect', () => setSocketState('offline'))
socket.on('presence.online', (e: { onlineCount?: number }) => { socket.on('presence.online', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
setOnlineCount(e.onlineCount ?? 0) socket.on('presence.offline', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
})
socket.on('presence.offline', (e: { onlineCount?: number }) => {
setOnlineCount(e.onlineCount ?? 0)
})
socket.on('typing.start', (e: { channelId?: string; userId?: string }) => { socket.on('typing.start', (e: { channelId?: string; userId?: string }) => {
if (!e.channelId || e.channelId !== channelId || !e.userId || e.userId === 'frontend-user') return if (!e.channelId || e.channelId !== channelId || !e.userId || e.userId === session?.user.id) return
setTypingUsers((prev) => (prev.includes(e.userId as string) ? prev : [...prev, e.userId as string])) setTypingUsers((prev) => (prev.includes(e.userId as string) ? prev : [...prev, e.userId as string]))
}) })
socket.on('typing.stop', (e: { channelId?: string; userId?: string }) => { socket.on('typing.stop', (e: { channelId?: string; userId?: string }) => {
@@ -78,20 +104,20 @@ export default function ChatPage() {
prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x)), prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x)),
), ),
) )
socket.connect()
socket.connect()
return () => { return () => {
socket.removeAllListeners() socket.removeAllListeners()
disconnectSocket() socket.disconnect()
} }
}, [socket, channelId, seqFrom, seqTo, limit]) }, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id])
async function pullMessages() { async function pullMessages() {
if (!channelId) return if (!channelId || !guild || !guildToken) return
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const res = await getApiClient().get(`/channels/${channelId}/messages`, { const res = await guildApi().get(`/channels/${channelId}/messages`, {
params: { params: {
seq_from: Number(seqFrom || '1'), seq_from: Number(seqFrom || '1'),
seq_to: Number(seqTo || '999999'), seq_to: Number(seqTo || '999999'),
@@ -108,14 +134,14 @@ export default function ChatPage() {
} }
async function sendMessage() { async function sendMessage() {
if (!channelId || !content.trim()) return if (!channelId || !content.trim() || !guild || !guildToken) return
setError('') setError('')
try { try {
await getApiClient().post(`/channels/${channelId}/messages`, { await guildApi().post(`/channels/${channelId}/messages`, {
content, content,
authorUserId: 'frontend-user', authorUserId: session?.user.id ?? 'unknown',
}) })
socket.emit('typing.stop', { channelId }) socket?.emit('typing.stop', { channelId })
setContent('') setContent('')
} catch { } catch {
setError('发送失败') setError('发送失败')
@@ -123,10 +149,10 @@ export default function ChatPage() {
} }
async function editMessage() { async function editMessage() {
if (!channelId || !editingMessageId || !editingContent.trim()) return if (!channelId || !editingMessageId || !editingContent.trim() || !guild || !guildToken) return
setError('') setError('')
try { try {
await getApiClient().patch(`/channels/${channelId}/messages/${editingMessageId}`, { await guildApi().patch(`/channels/${channelId}/messages/${editingMessageId}`, {
content: editingContent, content: editingContent,
}) })
setEditingContent('') setEditingContent('')
@@ -137,10 +163,10 @@ export default function ChatPage() {
} }
async function deleteMessage(messageId: string) { async function deleteMessage(messageId: string) {
if (!channelId || !messageId) return if (!channelId || !messageId || !guild || !guildToken) return
setError('') setError('')
try { try {
await getApiClient().delete(`/channels/${channelId}/messages/${messageId}`) await guildApi().delete(`/channels/${channelId}/messages/${messageId}`)
await pullMessages() await pullMessages()
} catch { } catch {
setError('删除失败') setError('删除失败')
@@ -161,13 +187,13 @@ export default function ChatPage() {
const wasEmpty = !content.trim() const wasEmpty = !content.trim()
const nowEmpty = !value.trim() const nowEmpty = !value.trim()
setContent(value) setContent(value)
if (!channelId) return if (!channelId || !socket) return
if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId }) if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId })
if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId }) if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId })
} }
useEffect(() => { useEffect(() => {
if (!channelId || !socket.connected) return if (!channelId || !socket || !socket.connected) return
socket.emit('join_channel', { channelId }) socket.emit('join_channel', { channelId })
return () => { return () => {
socket.emit('leave_channel', { channelId }) socket.emit('leave_channel', { channelId })
@@ -195,11 +221,7 @@ export default function ChatPage() {
<button onClick={sendMessage}></button> <button onClick={sendMessage}></button>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input <input value={editingMessageId} onChange={(e) => setEditingMessageId(e.target.value)} placeholder="messageId" />
value={editingMessageId}
onChange={(e) => setEditingMessageId(e.target.value)}
placeholder="messageId"
/>
<input value={editingContent} onChange={(e) => setEditingContent(e.target.value)} placeholder="新内容" /> <input value={editingContent} onChange={(e) => setEditingContent(e.target.value)} placeholder="新内容" />
<button onClick={editMessage}></button> <button onClick={editMessage}></button>
</div> </div>
@@ -216,8 +238,8 @@ export default function ChatPage() {
{!loading && !messages.length ? <p></p> : null} {!loading && !messages.length ? <p></p> : null}
{pageInfo ? ( {pageInfo ? (
<p> <p>
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq:{' '} next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} |
{pageInfo.highestCommittedSeq ?? '-'} | has_more: {String(pageInfo.hasMore ?? false)} has_more: {String(pageInfo.hasMore ?? false)}
</p> </p>
) : null} ) : null}
</section> </section>

View File

@@ -6,6 +6,7 @@ import { useAuth } from '../auth/auth-context'
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login, isAuthed, session } = useAuth() const { login, isAuthed, session } = useAuth()
const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -14,7 +15,7 @@ export default function LoginPage() {
e.preventDefault() e.preventDefault()
setError('') setError('')
try { try {
await login(email, password) await login(centerApiBase.trim(), email, password)
navigate('/workspace') navigate('/workspace')
} catch { } catch {
setError('登录失败,请检查账号密码') setError('登录失败,请检查账号密码')
@@ -26,6 +27,11 @@ export default function LoginPage() {
<h2></h2> <h2></h2>
{isAuthed ? <p>{session?.user.email}</p> : null} {isAuthed ? <p>{session?.user.email}</p> : null}
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 8, maxWidth: 420 }}> <form onSubmit={onSubmit} style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
<input
value={centerApiBase}
onChange={(e) => setCenterApiBase(e.target.value)}
placeholder="Center API Base (e.g. http://localhost:7001/api)"
/>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" /> <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
<input <input
value={password} value={password}

View File

@@ -1,106 +1,73 @@
import { useState } from 'react' import axios from 'axios'
import type { FormEvent } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { getApiClient, resetApiClient } from '../lib/api-client' import { useAuth } from '../auth/auth-context'
import {
getRuntimeConfig,
setRuntimeConfig,
} from '../lib/runtime-config'
import type { RuntimeConfig } from '../lib/runtime-config'
import { reconnectSocket } from '../lib/socket-client'
export default function WorkspacePage() { export default function WorkspacePage() {
const [form, setForm] = useState<RuntimeConfig>(getRuntimeConfig()) const { session } = useAuth()
const [health, setHealth] = useState('') const [health, setHealth] = useState('')
const [guilds, setGuilds] = useState<Array<{ id: string; name: string; slug: string }>>([])
const [channelsByGuild, setChannelsByGuild] = useState<Record<string, Array<{ id: string; name: string }>>>({}) const [channelsByGuild, setChannelsByGuild] = useState<Record<string, Array<{ id: string; name: string }>>>({})
async function onSave(e: FormEvent) { const tokenByGuild = useMemo(() => {
e.preventDefault() const map: Record<string, string> = {}
setRuntimeConfig(form) for (const item of session?.guildAccessTokens ?? []) {
resetApiClient() map[item.guildNodeId] = item.token
reconnectSocket()
setHealth('配置已保存')
} }
return map
}, [session])
async function checkHealth() { async function checkCenterHealth() {
if (!session) return
try { try {
const res = await getApiClient().get('/healthz') const res = await axios.get(`${session.centerApiBase}/healthz`)
setHealth(JSON.stringify(res.data)) setHealth(JSON.stringify(res.data))
} catch { } catch {
setHealth('healthz 访问失败') setHealth('center healthz 访问失败')
} }
} }
async function loadGuilds() { async function loadChannels(nodeId: string, endpoint: string) {
try { const token = tokenByGuild[nodeId]
const res = await getApiClient().get('/guilds') if (!token) {
const list = Array.isArray(res.data) ? res.data : [] setChannelsByGuild((prev) => ({ ...prev, [nodeId]: [] }))
setGuilds(list) return
} catch {
setGuilds([])
}
} }
async function loadChannels(guildId: string) {
try { try {
const res = await getApiClient().get('/channels', { params: { guildId } }) const res = await axios.get(`${endpoint}/api/channels`, {
headers: { Authorization: `Bearer ${token}` },
})
const list = Array.isArray(res.data) ? res.data : [] const list = Array.isArray(res.data) ? res.data : []
setChannelsByGuild((prev) => ({ ...prev, [guildId]: list })) setChannelsByGuild((prev) => ({ ...prev, [nodeId]: list }))
} catch { } catch {
setChannelsByGuild((prev) => ({ ...prev, [guildId]: [] })) setChannelsByGuild((prev) => ({ ...prev, [nodeId]: [] }))
} }
} }
return ( return (
<section> <section>
<h2></h2> <h2></h2>
<form onSubmit={onSave} style={{ display: 'grid', gap: 8, maxWidth: 760 }}> <p>Center: {session?.centerApiBase || '-'}</p>
<input <div style={{ marginBottom: 8 }}>
value={form.centerApiBase} <button type="button" onClick={checkCenterHealth}>
onChange={(e) => setForm((v) => ({ ...v, centerApiBase: e.target.value }))} Center healthz
placeholder="Center API Base"
/>
<input
value={form.guildApiBase}
onChange={(e) => setForm((v) => ({ ...v, guildApiBase: e.target.value }))}
placeholder="Guild API Base"
/>
<input
value={form.guildSocketBase}
onChange={(e) => setForm((v) => ({ ...v, guildSocketBase: e.target.value }))}
placeholder="Guild Socket Base"
/>
<input
value={form.apiKey}
onChange={(e) => setForm((v) => ({ ...v, apiKey: e.target.value }))}
placeholder="API Key"
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit"></button>
<button type="button" onClick={checkHealth}>
healthz
</button> </button>
</div> </div>
</form>
<p>{health}</p> <p>{health}</p>
<div style={{ marginTop: 12 }}>
<button type="button" onClick={loadGuilds}>
Guild
</button>
</div>
<ul> <ul>
{guilds.map((g) => ( {(session?.guilds ?? []).map((g) => (
<li key={g.id} style={{ marginTop: 8 }}> <li key={g.nodeId} style={{ marginTop: 8 }}>
<strong>{g.name}</strong> ({g.slug}){' '} <strong>{g.name}</strong> ({g.nodeId}) - {g.endpoint}{' '}
<button type="button" onClick={() => loadChannels(g.id)}> <button type="button" onClick={() => loadChannels(g.nodeId, g.endpoint)}>
</button> </button>
<ul> <ul>
{(channelsByGuild[g.id] ?? []).map((c) => ( {(channelsByGuild[g.nodeId] ?? []).map((c) => (
<li key={c.id}> <li key={c.id}>
<Link to={`/chat?guildId=${encodeURIComponent(g.id)}&channelId=${encodeURIComponent(c.id)}`}> <Link
to={`/chat?guildId=${encodeURIComponent(g.nodeId)}&channelId=${encodeURIComponent(c.id)}`}
>
#{c.name} #{c.name}
</Link> </Link>
</li> </li>