feat(frontend): public channels, self-excluded member picker, editable display name

- create-channel modal: Public checkbox (default off), self excluded from
  member list (creator auto-added), shows member display names
- Settings modal to edit your own display name
- members panel shows names and marks (you)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 09:09:49 +01:00
parent c10cdcf2e5
commit 6b35d92aef
5 changed files with 113 additions and 15 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, meGuildsCenter, refreshCenter } from '../lib/center-auth-client' import { loginCenter, logoutCenter, meGuildsCenter, refreshCenter, updateMeNameCenter } 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'
@@ -72,6 +72,22 @@ export function AuthProvider({ children }: PropsWithChildren) {
setAuthSession(next) setAuthSession(next)
setSession(next) setSession(next)
}, },
updateName: async (name: string) => {
if (!session) return
const accessToken = (await (async () => {
if (!isAccessTokenStale(session.accessToken)) return session.accessToken
const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
return refreshed.accessToken
})())
const updated = await updateMeNameCenter(session.centerApiBase, accessToken, name)
const next: AuthSession = {
...session,
accessToken,
user: { ...session.user, name: updated.name },
}
setAuthSession(next)
setSession(next)
},
}), }),
[session], [session],
) )

View File

@@ -8,6 +8,7 @@ export type AuthContextValue = {
logout: () => Promise<void> logout: () => Promise<void>
ensureFreshToken: () => Promise<string | null> ensureFreshToken: () => Promise<string | null>
refreshGuilds: () => Promise<void> refreshGuilds: () => Promise<void>
updateName: (name: string) => Promise<void>
} }
export const AuthContext = createContext<AuthContextValue | null>(null) export const AuthContext = createContext<AuthContextValue | null>(null)

View File

@@ -7,6 +7,7 @@ export type AuthSession = {
user: { user: {
id: string id: string
email: string email: string
name: string
} }
guilds: Array<{ guilds: Array<{
nodeId: string nodeId: string

View File

@@ -8,7 +8,7 @@ type LoginResponse = {
refreshToken: string refreshToken: string
tokenType: string tokenType: string
expiresIn?: number expiresIn?: number
user: { id: string; email: string } user: { id: string; email: string; name: string }
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }> guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }> guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
} }
@@ -74,10 +74,23 @@ export async function guildMembersCenter(
centerApiBase: string, centerApiBase: string,
accessToken: string, accessToken: string,
guildNodeId: string, guildNodeId: string,
): Promise<Array<{ userId: string; email: string; status: string }>> { ): Promise<Array<{ userId: string; email: string; name: string; status: string }>> {
const res = await centerClient(centerApiBase).get<Array<{ userId: string; email: string; status: string }>>( const res = await centerClient(centerApiBase).get<Array<{ userId: string; email: string; name: string; status: string }>>(
`/auth/guilds/${encodeURIComponent(guildNodeId)}/members`, `/auth/guilds/${encodeURIComponent(guildNodeId)}/members`,
{ headers: { Authorization: `Bearer ${accessToken}` } }, { headers: { Authorization: `Bearer ${accessToken}` } },
) )
return Array.isArray(res.data) ? res.data : [] return Array.isArray(res.data) ? res.data : []
} }
export async function updateMeNameCenter(
centerApiBase: string,
accessToken: string,
name: string,
): Promise<{ id: string; email: string; name: string }> {
const res = await centerClient(centerApiBase).patch<{ id: string; email: string; name: string }>(
'/auth/me',
{ name },
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
return res.data
}

View File

@@ -12,10 +12,10 @@ type MessageItem = {
} }
type GuildChannel = { id: string; name: string; guildId?: string } type GuildChannel = { id: string; name: string; guildId?: string }
type MemberItem = { userId: string; email: string; status: string } type MemberItem = { userId: string; email: string; name: string; status: string }
export default function ChatPage() { export default function ChatPage() {
const { session, logout, ensureFreshToken, refreshGuilds } = useAuth() const { session, logout, ensureFreshToken, refreshGuilds, updateName } = 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[]>([])
@@ -26,7 +26,10 @@ export default function ChatPage() {
const [newChannelName, setNewChannelName] = useState('') const [newChannelName, setNewChannelName] = useState('')
const [joinGuildNodeId, setJoinGuildNodeId] = useState('') const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]) const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
const [newChannelPublic, setNewChannelPublic] = useState(false)
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false) const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [settingsName, setSettingsName] = useState('')
const [showGuilds, setShowGuilds] = useState(true) const [showGuilds, setShowGuilds] = useState(true)
const [showChannels, setShowChannels] = useState(true) const [showChannels, setShowChannels] = useState(true)
const [showMembers, setShowMembers] = useState(true) const [showMembers, setShowMembers] = useState(true)
@@ -153,12 +156,14 @@ export default function ChatPage() {
const payload = { const payload = {
name: newChannelName.trim(), name: newChannelName.trim(),
guildId: effectiveGuildId, guildId: effectiveGuildId,
isPublic: newChannelPublic,
memberUserIds: selectedMemberIds, memberUserIds: selectedMemberIds,
} }
const res = await guildApi().post('/channels', payload) 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([]) setSelectedMemberIds([])
setNewChannelPublic(false)
setShowCreateChannelModal(false) setShowCreateChannelModal(false)
await loadChannels() await loadChannels()
if (createdId) setSelectedChannelId(createdId) if (createdId) setSelectedChannelId(createdId)
@@ -167,6 +172,21 @@ export default function ChatPage() {
} }
} }
async function saveName() {
const trimmed = settingsName.trim()
if (!trimmed) {
setError('Name must not be empty')
return
}
try {
await updateName(trimmed)
setShowSettingsModal(false)
await loadMembers()
} catch {
setError('Failed to update name')
}
}
useEffect(() => { useEffect(() => {
void loadMembers() void loadMembers()
setSelectedMemberIds([]) setSelectedMemberIds([])
@@ -263,7 +283,12 @@ 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.userId}><span className="muted">{m.email}</span></li> <li key={m.userId}>
<span className="muted">
{m.name || m.email}
{m.userId === session?.user.id ? ' (you)' : ''}
</span>
</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -274,7 +299,16 @@ export default function ChatPage() {
<button className="btn btn-secondary" onClick={() => setShowChannels((v) => !v)}>{showChannels ? 'Hide v2' : 'Show v2'}</button> <button className="btn btn-secondary" onClick={() => setShowChannels((v) => !v)}>{showChannels ? 'Hide v2' : 'Show v2'}</button>
<button className="btn btn-secondary" onClick={() => setShowMembers((v) => !v)}>{showMembers ? 'Hide v4' : 'Show v4'}</button> <button className="btn btn-secondary" onClick={() => setShowMembers((v) => !v)}>{showMembers ? 'Hide v4' : 'Show v4'}</button>
<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"
onClick={() => {
setSettingsName(session?.user.name ?? '')
setShowSettingsModal(true)
}}
>
Settings
</button>
</div> </div>
{showCreateChannelModal ? ( {showCreateChannelModal ? (
@@ -282,14 +316,27 @@ export default function ChatPage() {
<div className="modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Create channel</h3> <h3>Create channel</h3>
<input className="input" value={newChannelName} onChange={(e) => setNewChannelName(e.target.value)} placeholder="Channel name" /> <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> <label className="row-wrap" style={{ alignItems: 'center', marginTop: 8 }}>
<input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} />
<span>Public (visible to all guild members)</span>
</label>
<p className="muted" style={{ marginTop: 8 }}>
{newChannelPublic ? 'You are added automatically; all guild members can see it.' : 'You are added automatically. Select members to include'}
</p>
<div className="modal-list"> <div className="modal-list">
{members.map((m) => ( {members
<label key={m.userId} className="row-wrap" style={{ alignItems: 'center' }}> .filter((m) => m.userId !== session?.user.id)
<input type="checkbox" checked={selectedMemberIds.includes(m.userId)} onChange={() => toggleMember(m.userId)} /> .map((m) => (
<span>{m.email}</span> <label key={m.userId} className="row-wrap" style={{ alignItems: 'center' }}>
</label> <input
))} type="checkbox"
checked={selectedMemberIds.includes(m.userId)}
disabled={newChannelPublic}
onChange={() => toggleMember(m.userId)}
/>
<span>{m.name || m.email}</span>
</label>
))}
</div> </div>
<div className="row-wrap" style={{ marginTop: 12 }}> <div className="row-wrap" style={{ marginTop: 12 }}>
<button className="btn" onClick={createChannel}>Create</button> <button className="btn" onClick={createChannel}>Create</button>
@@ -299,6 +346,26 @@ export default function ChatPage() {
</div> </div>
) : null} ) : null}
{showSettingsModal ? (
<div className="modal-backdrop" onClick={() => setShowSettingsModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Settings</h3>
<p className="muted">Signed in as {session?.user.email}</p>
<p className="muted" style={{ marginTop: 8 }}>Display name</p>
<input
className="input"
value={settingsName}
onChange={(e) => setSettingsName(e.target.value)}
placeholder="Your name"
/>
<div className="row-wrap" style={{ marginTop: 12 }}>
<button className="btn" onClick={saveName}>Save</button>
<button className="btn btn-secondary" onClick={() => setShowSettingsModal(false)}>Cancel</button>
</div>
</div>
</div>
) : null}
{error ? ( {error ? (
<div className="modal-backdrop" onClick={() => setError('')}> <div className="modal-backdrop" onClick={() => setError('')}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card" onClick={(e) => e.stopPropagation()}>