feat(frontend): add guild join flow, members panel, and channel-create member modal

This commit is contained in:
nav
2026-05-14 16:58:12 +00:00
parent 75fee7c725
commit 40540ab8a6
5 changed files with 159 additions and 35 deletions

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage' import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage'
import type { AuthSession } from '../lib/auth-storage' import type { AuthSession } from '../lib/auth-storage'
import { loginCenter, logoutCenter, refreshCenter } from '../lib/center-auth-client' import { loginCenter, logoutCenter, meGuildsCenter, refreshCenter } from '../lib/center-auth-client'
import { AuthContext } from './auth-context' import { AuthContext } from './auth-context'
import type { AuthContextValue } from './auth-context' import type { AuthContextValue } from './auth-context'
@@ -45,6 +45,33 @@ export function AuthProvider({ children }: PropsWithChildren) {
setSession(next) setSession(next)
return next.accessToken return next.accessToken
}, },
refreshGuilds: async () => {
if (!session) return
let accessToken = session.accessToken
let refreshToken = session.refreshToken
let tokenType = session.tokenType
let expiresIn = session.expiresIn
if (isAccessTokenStale(session.accessToken)) {
const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
accessToken = refreshed.accessToken
refreshToken = refreshed.refreshToken
tokenType = refreshed.tokenType
expiresIn = refreshed.expiresIn
}
const guildData = await meGuildsCenter(session.centerApiBase, accessToken)
const next: AuthSession = {
...session,
accessToken,
refreshToken,
tokenType,
expiresIn,
guilds: guildData.guilds,
guildAccessTokens: guildData.guildAccessTokens,
}
setAuthSession(next)
setSession(next)
},
}), }),
[session], [session],
) )

View File

@@ -7,6 +7,7 @@ export type AuthContextValue = {
login: (centerApiBase: string, 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>
refreshGuilds: () => Promise<void>
} }
export const AuthContext = createContext<AuthContextValue | null>(null) export const AuthContext = createContext<AuthContextValue | null>(null)

View File

@@ -152,6 +152,31 @@ a:hover {
gap: 8px; gap: 8px;
} }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: grid;
place-items: center;
z-index: 30;
}
.modal-card {
width: min(560px, 92vw);
max-height: 80vh;
overflow: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.modal-list {
display: grid;
gap: 8px;
margin-top: 8px;
}
.list-btn { .list-btn {
width: 100%; width: 100%;
text-align: left; text-align: left;

View File

@@ -20,6 +20,11 @@ type RefreshResponse = {
expiresIn?: number expiresIn?: number
} }
type MeGuildsResponse = {
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
}
function centerClient(centerApiBase: string) { function centerClient(centerApiBase: string) {
const client = axios.create({ const client = axios.create({
baseURL: centerApiBase, baseURL: centerApiBase,
@@ -49,3 +54,30 @@ export async function refreshCenter(centerApiBase: string, refreshToken: string)
export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> { export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> {
await centerClient(centerApiBase).post('/auth/logout', { refreshToken }) await centerClient(centerApiBase).post('/auth/logout', { refreshToken })
} }
export async function meGuildsCenter(centerApiBase: string, accessToken: string): Promise<MeGuildsResponse> {
const res = await centerClient(centerApiBase).get<MeGuildsResponse>('/auth/me/guilds', {
headers: { Authorization: `Bearer ${accessToken}` },
})
return res.data
}
export async function joinGuildCenter(centerApiBase: string, accessToken: string, guildNodeId: string): Promise<void> {
await centerClient(centerApiBase).post(
'/auth/me/guilds/join',
{ guildNodeId },
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
}
export async function guildMembersCenter(
centerApiBase: string,
accessToken: string,
guildNodeId: string,
): Promise<Array<{ userId: string; email: string; status: string }>> {
const res = await centerClient(centerApiBase).get<Array<{ userId: string; email: string; status: string }>>(
`/auth/guilds/${encodeURIComponent(guildNodeId)}/members`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
return Array.isArray(res.data) ? res.data : []
}

View File

@@ -2,6 +2,7 @@ import axios from 'axios'
import { io, type Socket } from 'socket.io-client' import { io, type Socket } from 'socket.io-client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useAuth } from '../auth/auth-context' import { useAuth } from '../auth/auth-context'
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
type MessageItem = { type MessageItem = {
messageId: string messageId: string
@@ -11,22 +12,24 @@ type MessageItem = {
} }
type GuildChannel = { id: string; name: string; guildId?: string } type GuildChannel = { id: string; name: string; guildId?: string }
type GuildRecord = { id: string; name: string } type MemberItem = { userId: string; email: string; status: string }
type GuildMember = { id: string; userId: string; guildId: string; status: string }
export default function ChatPage() { export default function ChatPage() {
const { session, logout } = useAuth() const { session, logout, ensureFreshToken, refreshGuilds } = useAuth()
const [selectedGuildId, setSelectedGuildId] = useState('') const [selectedGuildId, setSelectedGuildId] = useState('')
const [selectedChannelId, setSelectedChannelId] = useState('') const [selectedChannelId, setSelectedChannelId] = useState('')
const [channels, setChannels] = useState<GuildChannel[]>([]) const [channels, setChannels] = useState<GuildChannel[]>([])
const [guildDbId, setGuildDbId] = useState('') const [guildDbId, setGuildDbId] = useState('')
const [members, setMembers] = useState<GuildMember[]>([]) const [members, setMembers] = useState<MemberItem[]>([])
const [showGuilds, setShowGuilds] = useState(true)
const [showChannels, setShowChannels] = useState(true)
const [showMembers, setShowMembers] = useState(true)
const [messages, setMessages] = useState<MessageItem[]>([]) const [messages, setMessages] = useState<MessageItem[]>([])
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [newChannelName, setNewChannelName] = useState('') const [newChannelName, setNewChannelName] = useState('')
const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
const [showGuilds, setShowGuilds] = useState(true)
const [showChannels, setShowChannels] = useState(true)
const [showMembers, setShowMembers] = useState(true)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -64,9 +67,7 @@ export default function ChatPage() {
if (!guild || !guildToken) return if (!guild || !guildToken) return
setError('') setError('')
try { try {
const res = await guildApi().get('/channels', { const res = await guildApi().get('/channels', { params: guildDbId ? { guildId: guildDbId } : undefined })
params: guildDbId ? { guildId: guildDbId } : undefined,
})
const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : [] const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
setChannels(list) setChannels(list)
if (!guildDbId && list[0]?.guildId) setGuildDbId(list[0].guildId) if (!guildDbId && list[0]?.guildId) setGuildDbId(list[0].guildId)
@@ -77,20 +78,14 @@ export default function ChatPage() {
} }
} }
async function loadGuildMetaAndMembers() { async function loadMembers() {
if (!guild || !guildToken) return if (!session || !selectedGuildId) return
setError('') setError('')
try { try {
const guildRes = await guildApi().get('/guilds') const token = await ensureFreshToken()
const guildList = Array.isArray(guildRes.data) ? (guildRes.data as GuildRecord[]) : [] if (!token) return
const nextGuildDbId = guildList[0]?.id ?? '' const list = await guildMembersCenter(session.centerApiBase, token, selectedGuildId)
setGuildDbId(nextGuildDbId) setMembers(list)
const memberRes = await guildApi().get('/members', {
params: nextGuildDbId ? { guildId: nextGuildDbId } : undefined,
})
const memberList = Array.isArray(memberRes.data) ? (memberRes.data as GuildMember[]) : []
setMembers(memberList)
} catch { } catch {
setError('Failed to load guild members') setError('Failed to load guild members')
setMembers([]) setMembers([])
@@ -128,6 +123,24 @@ export default function ChatPage() {
} }
} }
async function addGuild() {
if (!session || !joinGuildNodeId.trim()) return
setError('')
try {
const token = await ensureFreshToken()
if (!token) return
await joinGuildCenter(session.centerApiBase, token, joinGuildNodeId.trim())
await refreshGuilds()
setJoinGuildNodeId('')
} catch {
setError('Failed to add guild')
}
}
function toggleMember(userId: string) {
setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
}
async function createChannel() { async function createChannel() {
if (!guild || !guildToken || !newChannelName.trim()) return if (!guild || !guildToken || !newChannelName.trim()) return
if (!guildDbId) { if (!guildDbId) {
@@ -136,9 +149,16 @@ export default function ChatPage() {
} }
setError('') setError('')
try { try {
const res = await guildApi().post('/channels', { name: newChannelName.trim(), guildId: guildDbId }) const payload = {
name: newChannelName.trim(),
guildId: guildDbId,
memberUserIds: selectedMemberIds,
}
const res = await guildApi().post('/channels', payload)
const createdId = res.data?.id as string | undefined const createdId = res.data?.id as string | undefined
setNewChannelName('') setNewChannelName('')
setSelectedMemberIds([])
setShowCreateChannelModal(false)
await loadChannels() await loadChannels()
if (createdId) setSelectedChannelId(createdId) if (createdId) setSelectedChannelId(createdId)
} catch { } catch {
@@ -147,7 +167,8 @@ export default function ChatPage() {
} }
useEffect(() => { useEffect(() => {
void loadGuildMetaAndMembers() void loadMembers()
setSelectedMemberIds([])
}, [selectedGuildId]) }, [selectedGuildId])
useEffect(() => { useEffect(() => {
@@ -186,6 +207,10 @@ export default function ChatPage() {
<div className={`chat-top-grid ${!showGuilds ? 'hide-v1' : ''} ${!showChannels ? 'hide-v2' : ''} ${!showMembers ? 'hide-v4' : ''}`}> <div className={`chat-top-grid ${!showGuilds ? 'hide-v1' : ''} ${!showChannels ? 'hide-v2' : ''} ${!showMembers ? 'hide-v4' : ''}`}>
<div className="panel col-list col-v1"> <div className="panel col-list col-v1">
<h3>Guilds</h3> <h3>Guilds</h3>
<div className="row-wrap" style={{ marginBottom: 8 }}>
<input className="input" value={joinGuildNodeId} onChange={(e) => setJoinGuildNodeId(e.target.value)} placeholder="Guild nodeId" />
<button className="btn btn-secondary" onClick={addGuild}>Add guild</button>
</div>
<ul className="list-reset"> <ul className="list-reset">
{guilds.map((g) => ( {guilds.map((g) => (
<li key={g.nodeId}> <li key={g.nodeId}>
@@ -200,13 +225,7 @@ export default function ChatPage() {
<div className="panel col-list col-v2"> <div className="panel col-list col-v2">
<h3>Channels</h3> <h3>Channels</h3>
<div className="row-wrap" style={{ marginBottom: 8 }}> <div className="row-wrap" style={{ marginBottom: 8 }}>
<input <button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(true)}>Create channel</button>
className="input"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
placeholder="New channel name"
/>
<button className="btn btn-secondary" onClick={createChannel}>Create channel</button>
</div> </div>
<ul className="list-reset"> <ul className="list-reset">
{channels.map((c) => ( {channels.map((c) => (
@@ -244,9 +263,7 @@ export default function ChatPage() {
<h3>Members</h3> <h3>Members</h3>
<ul className="list-reset"> <ul className="list-reset">
{members.map((m) => ( {members.map((m) => (
<li key={m.id}> <li key={m.userId}><span className="muted">{m.email}</span></li>
<span className="muted">{m.userId}</span>
</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -259,6 +276,28 @@ export default function ChatPage() {
<button className="btn btn-secondary" onClick={() => void logout()}>Logout</button> <button className="btn btn-secondary" onClick={() => void logout()}>Logout</button>
<button className="btn btn-secondary" type="button">Settings</button> <button className="btn btn-secondary" type="button">Settings</button>
</div> </div>
{showCreateChannelModal ? (
<div className="modal-backdrop" onClick={() => setShowCreateChannelModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Create channel</h3>
<input className="input" value={newChannelName} onChange={(e) => setNewChannelName(e.target.value)} placeholder="Channel name" />
<p className="muted" style={{ marginTop: 8 }}>Select members to include</p>
<div className="modal-list">
{members.map((m) => (
<label key={m.userId} className="row-wrap" style={{ alignItems: 'center' }}>
<input type="checkbox" checked={selectedMemberIds.includes(m.userId)} onChange={() => toggleMember(m.userId)} />
<span>{m.email}</span>
</label>
))}
</div>
<div className="row-wrap" style={{ marginTop: 12 }}>
<button className="btn" onClick={createChannel}>Create</button>
<button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
</div>
</div>
</div>
) : null}
</section> </section>
) )
} }