Private
Public Access
Add user profile management: display name + avatar upload (Gitea issue #12)
Users can set a display name (shown instead of username in the message list, room member list, TopBar, and admin Users tab) and upload a real avatar, replacing the generated color-initial avatars everywhere a user appears. Avatars are square-cropped and downscaled to 512px, reusing app/storage.py's upload primitives from image uploads with a new square option. Two deliberate divergences from message-image handling, documented in backend/README.md: the previous avatar file is deleted on replace/remove (safe since it's strictly one file per user), and avatar serving is not room-gated and uses a short cache (identity-addressed and mutable, unlike a message image's permanent content-addressed URL). Frontend: new ProfileModal reachable from the TopBar account menu; AuthContext gains updateUser() so a profile change reflects instantly everywhere without a refetch.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { apiFetch } from './client'
|
||||
import { apiFetch, ApiError, NetworkError } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
// No register() here: this is an invite-only site. Accounts are created by
|
||||
@@ -19,3 +19,46 @@ export function logout(): Promise<void> {
|
||||
export function me(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me')
|
||||
}
|
||||
|
||||
export function updateProfile(displayName: string | null): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
})
|
||||
}
|
||||
|
||||
export function removeAvatar(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Not apiFetch: that wrapper always sets Content-Type: application/json,
|
||||
// which would stomp the multipart boundary the browser needs to set itself
|
||||
// for a file upload. Mirrors api/rooms.ts's uploadRoomImage.
|
||||
export async function uploadAvatar(file: File): Promise<User> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch('/api/auth/me/avatar', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
} catch {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText
|
||||
try {
|
||||
const body = await response.json()
|
||||
detail = body.detail ?? detail
|
||||
} catch {
|
||||
// response had no JSON body
|
||||
}
|
||||
throw new ApiError(response.status, detail)
|
||||
}
|
||||
|
||||
return (await response.json()) as User
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string {
|
||||
return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getRoomImageUrl } from '../api/rooms'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { senderColorIndex } from '../lib/messageGrouping'
|
||||
import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGrouping'
|
||||
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
@@ -24,8 +24,9 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
||||
const [reactingId, setReactingId] = useState<string | null>(null)
|
||||
|
||||
function usernameFor(userId: string): string {
|
||||
return members.find((m) => m.user_id === userId)?.username ?? 'someone'
|
||||
function displayNameForUserId(userId: string): string {
|
||||
const member = members.find((m) => m.user_id === userId)
|
||||
return member?.display_name || member?.username || 'someone'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,13 +60,17 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
|
||||
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
|
||||
<div className="message-avatar-slot">
|
||||
{isGroupStart && (
|
||||
<UserAvatar username={msg.username} colorIndex={senderColorIndex(msg.username, members)} />
|
||||
<UserAvatar
|
||||
username={msg.username}
|
||||
colorIndex={senderColorIndex(msg.username, members)}
|
||||
avatarUrl={avatarUrlFor(msg.username, members)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
{isGroupStart && (
|
||||
<div className="message-header">
|
||||
<span className="message-author">{msg.username}</span>
|
||||
<span className="message-author">{displayNameFor(msg.username, members)}</span>
|
||||
<span className="message-time">
|
||||
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
||||
</span>
|
||||
@@ -108,7 +113,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
|
||||
key={r.emoji}
|
||||
type="button"
|
||||
className={`message-reaction-pill${mineReaction ? ' message-reaction-pill-mine' : ''}`}
|
||||
title={r.user_ids.map(usernameFor).join(', ')}
|
||||
title={r.user_ids.map(displayNameForUserId).join(', ')}
|
||||
onClick={() => onReact(msg.id, r.emoji)}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
|
||||
@@ -182,3 +182,21 @@
|
||||
font-size: 0.76rem;
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
|
||||
.profile-modal-avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.profile-modal-avatar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { removeAvatar, updateProfile, uploadAvatar } from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import './Modal.css'
|
||||
|
||||
interface ProfileModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ProfileModal({ onClose }: ProfileModalProps) {
|
||||
const { user, updateUser } = useAuth()
|
||||
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [savingName, setSavingName] = useState(false)
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
async function handleSaveName(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSavingName(true)
|
||||
setError(null)
|
||||
try {
|
||||
const updated = await updateProfile(displayName.trim() || null)
|
||||
updateUser(updated)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
} finally {
|
||||
setSavingName(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setUploadingAvatar(true)
|
||||
setError(null)
|
||||
try {
|
||||
const updated = await uploadAvatar(file)
|
||||
updateUser(updated)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveAvatar() {
|
||||
setError(null)
|
||||
try {
|
||||
const updated = await removeAvatar()
|
||||
updateUser(updated)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
|
||||
|
||||
return (
|
||||
<div className="modal-scrim" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Profile settings</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="profile-modal-avatar-row">
|
||||
<UserAvatar
|
||||
username={user.username}
|
||||
colorIndex={hashIndex(user.username)}
|
||||
size={64}
|
||||
avatarUrl={avatarUrl}
|
||||
/>
|
||||
<div className="profile-modal-avatar-actions">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="modal-hidden-file-input"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
>
|
||||
{uploadingAvatar ? 'Uploading…' : 'Upload photo'}
|
||||
</button>
|
||||
{avatarUrl && (
|
||||
<button type="button" className="btn-secondary" onClick={handleRemoveAvatar}>
|
||||
Remove photo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveName}>
|
||||
<div className="modal-field-label">Display name</div>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={user.username}
|
||||
maxLength={50}
|
||||
/>
|
||||
{error && <p className="modal-error">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={savingName}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ApiError } from '../api/client'
|
||||
import { createInvite, listRoomInvites, revokeInvite } from '../api/invites'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import {
|
||||
changeMemberRole,
|
||||
deleteRoom,
|
||||
@@ -230,8 +231,13 @@ export function RoomInfoPanel({
|
||||
<div className="room-info-label">Members</div>
|
||||
{members.map((m, i) => (
|
||||
<div key={m.user_id} className="room-info-member-row">
|
||||
<UserAvatar username={m.username} colorIndex={i} size={24} />
|
||||
<span className="room-info-member-name">{m.username}</span>
|
||||
<UserAvatar
|
||||
username={m.username}
|
||||
colorIndex={i}
|
||||
size={24}
|
||||
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
|
||||
/>
|
||||
<span className="room-info-member-name">{m.display_name || m.username}</span>
|
||||
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
|
||||
{myRole === 'owner' && m.user_id !== user?.id && (
|
||||
<div className="room-info-member-actions">
|
||||
|
||||
@@ -40,16 +40,11 @@
|
||||
.top-bar-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--ds-accent-3);
|
||||
padding: 0;
|
||||
border: none;
|
||||
color: var(--ds-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.top-bar-menu-scrim {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import logo from '../assets/logo.png'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { initials } from '../lib/avatar'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
|
||||
import { ProfileModal } from './ProfileModal'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import './TopBar.css'
|
||||
|
||||
export function TopBar() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false)
|
||||
const [pushSubscribed, setPushSubscribed] = useState(false)
|
||||
const [pushBusy, setPushBusy] = useState(false)
|
||||
const [pushError, setPushError] = useState<string | null>(null)
|
||||
@@ -53,13 +57,28 @@ export function TopBar() {
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Account menu"
|
||||
>
|
||||
{initials(user.username)}
|
||||
<UserAvatar
|
||||
username={user.username}
|
||||
colorIndex={hashIndex(user.username)}
|
||||
size={30}
|
||||
avatarUrl={user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null}
|
||||
/>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} />
|
||||
<div className="top-bar-menu" role="menu">
|
||||
<div className="top-bar-menu-username">{user.username}</div>
|
||||
<div className="top-bar-menu-username">{user.display_name || user.username}</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setProfileModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Profile settings
|
||||
</button>
|
||||
{user.is_site_admin && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -90,6 +109,7 @@ export function TopBar() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{profileModalOpen && <ProfileModal onClose={() => setProfileModalOpen(false)} />}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
color: var(--ds-text);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.user-avatar-img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,21 @@ interface UserAvatarProps {
|
||||
username: string
|
||||
colorIndex: number
|
||||
size?: number
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export function UserAvatar({ username, colorIndex, size = 28 }: UserAvatarProps) {
|
||||
export function UserAvatar({ username, colorIndex, size = 28, avatarUrl }: UserAvatarProps) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="user-avatar user-avatar-img"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="user-avatar"
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AuthContextValue {
|
||||
offline: boolean
|
||||
login: (usernameOrEmail: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
updateUser: (user: User) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
@@ -67,8 +68,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
clearLastUser()
|
||||
}
|
||||
|
||||
function updateUser(u: User) {
|
||||
setUser(u)
|
||||
saveLastUser(u)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, offline, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, loading, offline, login, logout, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -18,3 +18,9 @@ export function initials(name: string): string {
|
||||
export function accentForIndex(index: number): string {
|
||||
return ACCENT_CYCLE[((index % ACCENT_CYCLE.length) + ACCENT_CYCLE.length) % ACCENT_CYCLE.length]
|
||||
}
|
||||
|
||||
export function hashIndex(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) hash = (hash * 31 + str.charCodeAt(i)) | 0
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { hashIndex } from './avatar'
|
||||
import type { RoomMember } from '../types'
|
||||
|
||||
export function senderColorIndex(username: string, members: RoomMember[]): number {
|
||||
@@ -5,7 +7,16 @@ export function senderColorIndex(username: string, members: RoomMember[]): numbe
|
||||
if (idx >= 0) return idx
|
||||
// Fallback for a sender no longer in the room (e.g. they left): derive a
|
||||
// stable index from the username instead of always colliding on 0.
|
||||
let hash = 0
|
||||
for (let i = 0; i < username.length; i++) hash = (hash * 31 + username.charCodeAt(i)) | 0
|
||||
return Math.abs(hash)
|
||||
return hashIndex(username)
|
||||
}
|
||||
|
||||
export function avatarUrlFor(username: string, members: RoomMember[]): string | null {
|
||||
const member = members.find((m) => m.username === username)
|
||||
if (!member?.avatar_filename) return null
|
||||
return getUserAvatarUrl(member.user_id, member.avatar_filename)
|
||||
}
|
||||
|
||||
export function displayNameFor(username: string, members: RoomMember[]): string {
|
||||
const member = members.find((m) => m.username === username)
|
||||
return member?.display_name || username
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
} from '../api/admin'
|
||||
import { ApiError } from '../api/client'
|
||||
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
import type {
|
||||
AdminRoom,
|
||||
AdminUser,
|
||||
@@ -29,6 +31,7 @@ import type {
|
||||
WebhookIncomingAdmin,
|
||||
} from '../types'
|
||||
import { TopBar } from '../components/TopBar'
|
||||
import { UserAvatar } from '../components/UserAvatar'
|
||||
import './AdminPage.css'
|
||||
|
||||
type Tab = 'users' | 'rooms' | 'bots' | 'audit' | 'settings'
|
||||
@@ -243,6 +246,7 @@ export function AdminPage() {
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
@@ -253,7 +257,15 @@ export function AdminPage() {
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}</td>
|
||||
<td>
|
||||
<UserAvatar
|
||||
username={u.username}
|
||||
colorIndex={hashIndex(u.username)}
|
||||
size={28}
|
||||
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
|
||||
/>
|
||||
</td>
|
||||
<td>{u.display_name || u.username}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${u.is_active ? 'active' : 'inactive'}`}>
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface User {
|
||||
email: string
|
||||
is_bot: boolean
|
||||
is_site_admin: boolean
|
||||
display_name: string | null
|
||||
avatar_filename: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -29,6 +31,8 @@ export interface MyRoomItem extends Room {
|
||||
export interface RoomMember {
|
||||
user_id: string
|
||||
username: string
|
||||
display_name: string | null
|
||||
avatar_filename: string | null
|
||||
role: RoomRole
|
||||
joined_at: string
|
||||
}
|
||||
@@ -121,6 +125,8 @@ export interface AdminUser {
|
||||
is_bot: boolean
|
||||
is_site_admin: boolean
|
||||
is_active: boolean
|
||||
display_name: string | null
|
||||
avatar_filename: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user