feat(frontend): add guild join flow, members panel, and channel-create member modal
This commit is contained in:
@@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 : []
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user