From c906cde2097d04c6f7b97c47aac8b5de6b3d0be4 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 13 May 2026 08:00:23 +0000 Subject: [PATCH] feat(frontend): login with center URL and consume center-issued guild tokens --- .dockerignore | 7 +++ Dockerfile | 15 +++++ docker/nginx.conf | 17 ++++++ src/auth/AuthContext.tsx | 8 +-- src/auth/auth-context.ts | 2 +- src/lib/auth-storage.ts | 12 ++++ src/lib/center-auth-client.ts | 29 +++++---- src/pages/ChatPage.tsx | 88 +++++++++++++++++---------- src/pages/LoginPage.tsx | 8 ++- src/pages/WorkspacePage.tsx | 109 ++++++++++++---------------------- 10 files changed, 170 insertions(+), 125 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfb32a8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.gitignore +Dockerfile +*.log +.env* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..febfe46 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..6dd6498 --- /dev/null +++ b/docker/nginx.conf @@ -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; + } +} diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 6b104c2..7adfd2c 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -13,15 +13,15 @@ export function AuthProvider({ children }: PropsWithChildren) { () => ({ session, isAuthed: !!session, - login: async (email: string, password: string) => { - const next = await loginCenter({ email, password }) + login: async (centerApiBase: string, email: string, password: string) => { + const next = await loginCenter(centerApiBase, { email, password }) setAuthSession(next) setSession(next) }, logout: async () => { if (session?.refreshToken) { try { - await logoutCenter(session.refreshToken) + await logoutCenter(session.centerApiBase, session.refreshToken) } catch { // noop } @@ -33,7 +33,7 @@ export function AuthProvider({ children }: PropsWithChildren) { if (!session) return null if (!isAccessTokenStale(session.accessToken)) return session.accessToken - const refreshed = await refreshCenter(session.refreshToken) + const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken) const next: AuthSession = { ...session, accessToken: refreshed.accessToken, diff --git a/src/auth/auth-context.ts b/src/auth/auth-context.ts index 74b8dbf..51c816e 100644 --- a/src/auth/auth-context.ts +++ b/src/auth/auth-context.ts @@ -4,7 +4,7 @@ import type { AuthSession } from '../lib/auth-storage' export type AuthContextValue = { session: AuthSession | null isAuthed: boolean - login: (email: string, password: string) => Promise + login: (centerApiBase: string, email: string, password: string) => Promise logout: () => Promise ensureFreshToken: () => Promise } diff --git a/src/lib/auth-storage.ts b/src/lib/auth-storage.ts index 8a84ff4..5267247 100644 --- a/src/lib/auth-storage.ts +++ b/src/lib/auth-storage.ts @@ -1,4 +1,5 @@ export type AuthSession = { + centerApiBase: string accessToken: string refreshToken: string tokenType: string @@ -6,6 +7,17 @@ export type AuthSession = { id: 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' diff --git a/src/lib/center-auth-client.ts b/src/lib/center-auth-client.ts index 86f7ce3..6225802 100644 --- a/src/lib/center-auth-client.ts +++ b/src/lib/center-auth-client.ts @@ -1,14 +1,16 @@ import axios from 'axios' -import { getRuntimeConfig } from './runtime-config' import type { AuthSession } from './auth-storage' export type LoginPayload = { email: string; password: string } type LoginResponse = { + centerApiBase: string accessToken: string refreshToken: string tokenType: 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 = { @@ -17,17 +19,14 @@ type RefreshResponse = { tokenType: string } -function centerClient() { - const cfg = getRuntimeConfig() +function centerClient(centerApiBase: string) { const client = axios.create({ - baseURL: cfg.centerApiBase, + baseURL: centerApiBase, timeout: 10000, }) client.interceptors.request.use((request) => { - const { apiKey } = getRuntimeConfig() const requestId = crypto.randomUUID() - if (apiKey) request.headers['x-api-key'] = apiKey request.headers['x-request-id'] = requestId request.headers['x-client-name'] = 'fabric-frontend' return request @@ -36,16 +35,16 @@ function centerClient() { return client } -export async function loginCenter(payload: LoginPayload): Promise { - const res = await centerClient().post('/auth/login', payload) +export async function loginCenter(centerApiBase: string, payload: LoginPayload): Promise { + const res = await centerClient(centerApiBase).post('/auth/login', payload) + return { ...res.data, centerApiBase } +} + +export async function refreshCenter(centerApiBase: string, refreshToken: string): Promise { + const res = await centerClient(centerApiBase).post('/auth/refresh', { refreshToken }) return res.data } -export async function refreshCenter(refreshToken: string): Promise { - const res = await centerClient().post('/auth/refresh', { refreshToken }) - return res.data -} - -export async function logoutCenter(refreshToken: string): Promise { - await centerClient().post('/auth/logout', { refreshToken }) +export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise { + await centerClient(centerApiBase).post('/auth/logout', { refreshToken }) } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 6c08d3a..41631d7 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,7 +1,8 @@ +import axios from 'axios' +import { io, type Socket } from 'socket.io-client' import { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { getApiClient } from '../lib/api-client' -import { disconnectSocket, getSocketClient } from '../lib/socket-client' +import { useAuth } from '../auth/auth-context' type MessageItem = { messageId: string @@ -17,6 +18,7 @@ type PageInfo = { } export default function ChatPage() { + const { session } = useAuth() const [searchParams, setSearchParams] = useSearchParams() const guildId = searchParams.get('guildId') ?? '' const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '') @@ -34,14 +36,42 @@ export default function ChatPage() { const [editingMessageId, setEditingMessageId] = 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(() => { + if (!guild || !guildToken) return null + return io(`${guild.endpoint}/realtime`, { + transports: ['websocket'], + auth: { + token: guildToken, + }, + autoConnect: false, + }) + }, [guild, guildToken]) useEffect(() => { + if (!socket) return + socket.on('connect', () => { setSocketState('online') if (!channelId) return socket.emit('join_channel', { channelId }) - void getApiClient() + void guildApi() .get(`/channels/${channelId}/messages`, { params: { seq_from: Number(seqFrom || '1'), @@ -55,14 +85,10 @@ export default function ChatPage() { }) }) socket.on('disconnect', () => setSocketState('offline')) - socket.on('presence.online', (e: { onlineCount?: number }) => { - setOnlineCount(e.onlineCount ?? 0) - }) - socket.on('presence.offline', (e: { onlineCount?: number }) => { - setOnlineCount(e.onlineCount ?? 0) - }) + socket.on('presence.online', (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 }) => { - 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])) }) 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)), ), ) - socket.connect() + socket.connect() return () => { socket.removeAllListeners() - disconnectSocket() + socket.disconnect() } - }, [socket, channelId, seqFrom, seqTo, limit]) + }, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id]) async function pullMessages() { - if (!channelId) return + if (!channelId || !guild || !guildToken) return setLoading(true) setError('') try { - const res = await getApiClient().get(`/channels/${channelId}/messages`, { + const res = await guildApi().get(`/channels/${channelId}/messages`, { params: { seq_from: Number(seqFrom || '1'), seq_to: Number(seqTo || '999999'), @@ -108,14 +134,14 @@ export default function ChatPage() { } async function sendMessage() { - if (!channelId || !content.trim()) return + if (!channelId || !content.trim() || !guild || !guildToken) return setError('') try { - await getApiClient().post(`/channels/${channelId}/messages`, { + await guildApi().post(`/channels/${channelId}/messages`, { content, - authorUserId: 'frontend-user', + authorUserId: session?.user.id ?? 'unknown', }) - socket.emit('typing.stop', { channelId }) + socket?.emit('typing.stop', { channelId }) setContent('') } catch { setError('发送失败') @@ -123,10 +149,10 @@ export default function ChatPage() { } async function editMessage() { - if (!channelId || !editingMessageId || !editingContent.trim()) return + if (!channelId || !editingMessageId || !editingContent.trim() || !guild || !guildToken) return setError('') try { - await getApiClient().patch(`/channels/${channelId}/messages/${editingMessageId}`, { + await guildApi().patch(`/channels/${channelId}/messages/${editingMessageId}`, { content: editingContent, }) setEditingContent('') @@ -137,10 +163,10 @@ export default function ChatPage() { } async function deleteMessage(messageId: string) { - if (!channelId || !messageId) return + if (!channelId || !messageId || !guild || !guildToken) return setError('') try { - await getApiClient().delete(`/channels/${channelId}/messages/${messageId}`) + await guildApi().delete(`/channels/${channelId}/messages/${messageId}`) await pullMessages() } catch { setError('删除失败') @@ -161,13 +187,13 @@ export default function ChatPage() { const wasEmpty = !content.trim() const nowEmpty = !value.trim() setContent(value) - if (!channelId) return + if (!channelId || !socket) return if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId }) if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId }) } useEffect(() => { - if (!channelId || !socket.connected) return + if (!channelId || !socket || !socket.connected) return socket.emit('join_channel', { channelId }) return () => { socket.emit('leave_channel', { channelId }) @@ -195,11 +221,7 @@ export default function ChatPage() {
- setEditingMessageId(e.target.value)} - placeholder="messageId" - /> + setEditingMessageId(e.target.value)} placeholder="messageId" /> setEditingContent(e.target.value)} placeholder="新内容" />
@@ -216,8 +238,8 @@ export default function ChatPage() { {!loading && !messages.length ?

暂无消息

: null} {pageInfo ? (

- next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq:{' '} - {pageInfo.highestCommittedSeq ?? '-'} | has_more: {String(pageInfo.hasMore ?? false)} + next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} | + has_more: {String(pageInfo.hasMore ?? false)}

) : null} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 9331ac3..e6d32f4 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -6,6 +6,7 @@ import { useAuth } from '../auth/auth-context' export default function LoginPage() { const navigate = useNavigate() const { login, isAuthed, session } = useAuth() + const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') @@ -14,7 +15,7 @@ export default function LoginPage() { e.preventDefault() setError('') try { - await login(email, password) + await login(centerApiBase.trim(), email, password) navigate('/workspace') } catch { setError('登录失败,请检查账号密码') @@ -26,6 +27,11 @@ export default function LoginPage() {

登录

{isAuthed ?

当前用户:{session?.user.email}

: null}
+ setCenterApiBase(e.target.value)} + placeholder="Center API Base (e.g. http://localhost:7001/api)" + /> setEmail(e.target.value)} placeholder="Email" type="email" /> (getRuntimeConfig()) + const { session } = useAuth() const [health, setHealth] = useState('') - const [guilds, setGuilds] = useState>([]) const [channelsByGuild, setChannelsByGuild] = useState>>({}) - async function onSave(e: FormEvent) { - e.preventDefault() - setRuntimeConfig(form) - resetApiClient() - reconnectSocket() - setHealth('配置已保存') - } + const tokenByGuild = useMemo(() => { + const map: Record = {} + for (const item of session?.guildAccessTokens ?? []) { + map[item.guildNodeId] = item.token + } + return map + }, [session]) - async function checkHealth() { + async function checkCenterHealth() { + if (!session) return try { - const res = await getApiClient().get('/healthz') + const res = await axios.get(`${session.centerApiBase}/healthz`) setHealth(JSON.stringify(res.data)) } catch { - setHealth('healthz 访问失败') + setHealth('center healthz 访问失败') } } - async function loadGuilds() { - try { - const res = await getApiClient().get('/guilds') - const list = Array.isArray(res.data) ? res.data : [] - setGuilds(list) - } catch { - setGuilds([]) + async function loadChannels(nodeId: string, endpoint: string) { + const token = tokenByGuild[nodeId] + if (!token) { + setChannelsByGuild((prev) => ({ ...prev, [nodeId]: [] })) + return } - } - async function loadChannels(guildId: string) { 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 : [] - setChannelsByGuild((prev) => ({ ...prev, [guildId]: list })) + setChannelsByGuild((prev) => ({ ...prev, [nodeId]: list })) } catch { - setChannelsByGuild((prev) => ({ ...prev, [guildId]: [] })) + setChannelsByGuild((prev) => ({ ...prev, [nodeId]: [] })) } } return (

工作台

- - setForm((v) => ({ ...v, centerApiBase: e.target.value }))} - placeholder="Center API Base" - /> - setForm((v) => ({ ...v, guildApiBase: e.target.value }))} - placeholder="Guild API Base" - /> - setForm((v) => ({ ...v, guildSocketBase: e.target.value }))} - placeholder="Guild Socket Base" - /> - setForm((v) => ({ ...v, apiKey: e.target.value }))} - placeholder="API Key" - /> -
- - -
- -

{health}

-
-
+

{health}

    - {guilds.map((g) => ( -
  • - {g.name} ({g.slug}){' '} -
      - {(channelsByGuild[g.id] ?? []).map((c) => ( + {(channelsByGuild[g.nodeId] ?? []).map((c) => (
    • - + #{c.name}