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:
2026-08-14 16:59:56 -06:00
parent c6f90d49fc
commit 8ca3e2e23d
28 changed files with 689 additions and 41 deletions
+46 -7
View File
@@ -1,4 +1,4 @@
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions)
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a
@@ -6,9 +6,10 @@ WebSocket chat endpoint that fans out across multiple app-server instances
via Redis pub/sub, Web Push notifications for offline room members, a
site-admin portal (user/room/bot management + an audit log), a bot/
extension layer (scoped API tokens, live bot WebSocket access, incoming and
outgoing webhooks, message editing), image uploads in chat messages, and
emoji reactions on messages. See `../ARCHITECTURE.md` for the full system
design and the phased build plan.
outgoing webhooks, message editing), image uploads in chat messages, emoji
reactions on messages, and self-service user profiles (display name,
avatar). See `../ARCHITECTURE.md` for the full system design and the
phased build plan.
This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server — see step 4 below.
@@ -116,8 +117,9 @@ app/
require_room_member, require_room_role,
require_site_admin, require_scope
security.py argon2 password hashing + token generate/hash (sha256)
storage.py uploaded-image validation (Pillow), downscaling,
and on-disk save/read -- see Image uploads below
storage.py uploaded-image validation (Pillow), downscaling
(optionally square-cropped, for avatars), and
on-disk save/read -- see Image uploads below
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, message_reactions,
@@ -125,7 +127,8 @@ app/
admin_audit_log, api_tokens, webhooks_incoming,
event_subscriptions)
schemas/ Pydantic request/response models
routers/ auth, rooms, invites, push, admin, bots, webhooks, health
routers/ auth, rooms, users, invites, push, admin, bots,
webhooks, health
services/ business logic called by routers
ws/ connection_manager (local sockets), presence +
broadcaster (Redis), /ws/chat handler
@@ -376,6 +379,42 @@ in `webhook_service.py` is unchanged — same restraint as image uploads), no
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
only, curated client-side list in `frontend/src/lib/emoji.ts`).
## User profiles
Display name and avatar live directly on `User`
(`display_name`, `avatar_filename`, `avatar_content_type`, all nullable) —
no separate profile table, since it's a strict 1:1 with no room-scoping
concern the way message images have. Bio was considered and explicitly
scoped out.
- `PATCH /api/auth/me` — updates `display_name` (`app/routers/auth.py`).
Empty/whitespace clears it back to `None`, falling back to the username
everywhere it's displayed.
- `POST /api/auth/me/avatar` / `DELETE /api/auth/me/avatar` — reuse
`app/storage.py`'s upload primitives (`read_capped`, `process_image`,
`save_image`) from image uploads, but call `process_image(..., square=True,
max_dimension=512)` — a new option that center-crops before downscaling,
since avatars need a fixed square shape at a much smaller size than a
message image. Unlike message images (which never delete), the previous
avatar file **is deleted** on replace/remove (`storage.delete_image`) —
safe to do here because it's strictly one file per user, no accumulation
risk to accept the way an orphaned message-image upload has.
- `GET /api/users/{user_id}/avatar` (`app/routers/users.py`, new router) —
serves the file. Two deliberate divergences from the message-image
serving endpoint: **not** room-membership-gated (avatar visibility
matches username visibility — any authenticated user can see anyone's),
and `Cache-Control: private, max-age=300` rather than `immutable` (an
avatar URL is identity-addressed and its content changes on re-upload,
unlike a message image's permanent content-addressed URL).
`RoomMemberRead` and `AdminUserRead` both carry `display_name`/
`avatar_filename` so the frontend can render an avatar and a preferred name
anywhere a user appears (message list, room member list, admin Users tab,
TopBar) — `MessageRead` deliberately does **not** carry them; the frontend
resolves both live from the room's member list instead of freezing them
per-message, which is the more correct behavior for a field the sender can
change after the fact.
## Notes / scope decisions
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
@@ -0,0 +1,36 @@
"""user profile fields
Revision ID: f6e024985d4d
Revises: 1d355add7299
Create Date: 2026-08-14 16:47:04.360957
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f6e024985d4d'
down_revision: Union[str, Sequence[str], None] = '1d355add7299'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('display_name', sa.String(length=50), nullable=True))
op.add_column('users', sa.Column('avatar_filename', sa.String(length=64), nullable=True))
op.add_column('users', sa.Column('avatar_content_type', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'avatar_content_type')
op.drop_column('users', 'avatar_filename')
op.drop_column('users', 'display_name')
# ### end Alembic commands ###
+2 -1
View File
@@ -11,7 +11,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import admin, auth, bots, health, invites, push, rooms, webhooks
from app.routers import admin, auth, bots, health, invites, push, rooms, users, webhooks
from app.ws.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -73,6 +73,7 @@ def create_app() -> FastAPI:
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(rooms.router)
app.include_router(users.router)
app.include_router(invites.router)
app.include_router(push.router)
app.include_router(admin.router)
+3
View File
@@ -17,6 +17,9 @@ class User(Base):
is_bot: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_site_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50))
avatar_filename: Mapped[str | None] = mapped_column(String(64))
avatar_content_type: Mapped[str | None] = mapped_column(String(50))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+77 -2
View File
@@ -1,16 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.auth import LoginRequest
from app.schemas.user import UserRead
from app.schemas.user import ProfileUpdate, UserRead
from app.services.auth_service import (
AccountDeactivatedError,
InvalidCredentialsError,
authenticate_user,
)
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
ImageTooLargeError,
InvalidImageError,
delete_image,
process_image,
read_capped,
save_image,
)
AVATAR_MAX_DIMENSION = 512
# No POST /register here: this is an invite-only site. Accounts are created
# by an operator via `python -m app.cli create-user` (see app/cli.py), not
@@ -45,3 +56,67 @@ async def logout(request: Request) -> Response:
@router.get("/me", response_model=UserRead)
async def me(current_user: User = Depends(get_current_user)) -> User:
return current_user
@router.patch("/me", response_model=UserRead)
async def update_profile(
data: ProfileUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
display_name = data.display_name.strip() if data.display_name else None
current_user.display_name = display_name or None
await db.commit()
await db.refresh(current_user)
return current_user
@router.post("/me/avatar", response_model=UserRead)
async def upload_avatar(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Unsupported image type")
try:
data = await read_capped(file)
except ImageTooLargeError:
raise HTTPException(status_code=413, detail="Image exceeds 8 MB limit")
try:
data, ext = process_image(
data, file.content_type, square=True, max_dimension=AVATAR_MAX_DIMENSION
)
except InvalidImageError:
raise HTTPException(status_code=400, detail="File is not a valid image")
previous_filename = current_user.avatar_filename
storage_filename = save_image(data, ext)
current_user.avatar_filename = storage_filename
current_user.avatar_content_type = file.content_type
await db.commit()
await db.refresh(current_user)
if previous_filename:
delete_image(previous_filename)
return current_user
@router.delete("/me/avatar", response_model=UserRead)
async def remove_avatar(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
previous_filename = current_user.avatar_filename
current_user.avatar_filename = None
current_user.avatar_content_type = None
await db.commit()
await db.refresh(current_user)
if previous_filename:
delete_image(previous_filename)
return current_user
+2
View File
@@ -63,6 +63,8 @@ async def accept_invite_endpoint(
return RoomMemberRead(
user_id=membership.user_id,
username=current_user.username,
display_name=current_user.display_name,
avatar_filename=current_user.avatar_filename,
role=membership.role,
joined_at=membership.joined_at,
)
+8 -1
View File
@@ -214,7 +214,12 @@ async def list_room_members_endpoint(
memberships = await list_room_members(db, room_id)
return [
RoomMemberRead(
user_id=m.user_id, username=m.user.username, role=m.role, joined_at=m.joined_at
user_id=m.user_id,
username=m.user.username,
display_name=m.user.display_name,
avatar_filename=m.user.avatar_filename,
role=m.role,
joined_at=m.joined_at,
)
for m in memberships
]
@@ -260,6 +265,8 @@ async def change_member_role_endpoint(
return RoomMemberRead(
user_id=membership.user_id,
username=membership.user.username,
display_name=membership.user.display_name,
avatar_filename=membership.user.avatar_filename,
role=membership.role,
joined_at=membership.joined_at,
)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.storage import UPLOADS_DIR
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/{user_id}/avatar")
async def get_user_avatar_endpoint(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user = await db.get(User, user_id)
if user is None or not user.avatar_filename:
raise HTTPException(status_code=404, detail="No avatar set")
return FileResponse(
UPLOADS_DIR / user.avatar_filename,
media_type=user.avatar_content_type,
# Unlike message images (content-addressed, immutable once posted),
# an avatar URL is identity-addressed and its content can change on
# re-upload -- a short cache instead of `immutable` so a stale copy
# doesn't linger. Not room-membership-gated: avatar visibility
# matches username visibility (anyone logged in), unlike room-scoped
# message content.
headers={"Cache-Control": "private, max-age=300"},
)
+2
View File
@@ -13,6 +13,8 @@ class AdminUserRead(BaseModel):
is_bot: bool
is_site_admin: bool
is_active: bool
display_name: str | None
avatar_filename: str | None
created_at: datetime
+2
View File
@@ -39,6 +39,8 @@ class MyRoomItem(RoomRead):
class RoomMemberRead(BaseModel):
user_id: uuid.UUID
username: str
display_name: str | None
avatar_filename: str | None
role: RoomRole
joined_at: datetime
+6
View File
@@ -18,4 +18,10 @@ class UserRead(BaseModel):
email: EmailStr
is_bot: bool
is_site_admin: bool
display_name: str | None
avatar_filename: str | None
created_at: datetime
class ProfileUpdate(BaseModel):
display_name: str | None = Field(default=None, max_length=50)
+24 -4
View File
@@ -48,12 +48,21 @@ async def read_capped(file, cap: int = MAX_IMAGE_BYTES) -> bytes:
return b"".join(chunks)
def process_image(data: bytes, content_type: str) -> tuple[bytes, str]:
def process_image(
data: bytes,
content_type: str,
*,
square: bool = False,
max_dimension: int | None = None,
) -> tuple[bytes, str]:
"""Confirms `data` is a genuinely decodable image (not just a spoofed
Content-Type header) and downscales it so its longer side is
<=2000px -- except GIF, left untouched so animation isn't collapsed to
a single frame. Returns (final_bytes, storage_extension)."""
<=max_dimension (default 2000px) -- except GIF, left untouched so
animation isn't collapsed to a single frame. When `square` is set
(avatars), center-crops to the shorter side first. Returns
(final_bytes, storage_extension)."""
ext, pillow_format = ALLOWED_IMAGE_CONTENT_TYPES[content_type]
dimension_cap = max_dimension or _MAX_DIMENSION
try:
with Image.open(io.BytesIO(data)) as probe:
@@ -69,7 +78,12 @@ def process_image(data: bytes, content_type: str) -> tuple[bytes, str]:
image.load()
if pillow_format == "JPEG" and image.mode in ("RGBA", "P"):
image = image.convert("RGB")
image.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION))
if square:
side = min(image.width, image.height)
left = (image.width - side) // 2
top = (image.height - side) // 2
image = image.crop((left, top, left + side, top + side))
image.thumbnail((dimension_cap, dimension_cap))
out = io.BytesIO()
image.save(out, format=pillow_format)
return out.getvalue(), ext
@@ -80,3 +94,9 @@ def save_image(data: bytes, ext: str) -> str:
storage_filename = f"{uuid.uuid4()}{ext}"
(UPLOADS_DIR / storage_filename).write_bytes(data)
return storage_filename
def delete_image(storage_filename: str) -> None:
"""Best-effort delete -- a missing file (already gone, or never
written) is not an error."""
(UPLOADS_DIR / storage_filename).unlink(missing_ok=True)
+145
View File
@@ -0,0 +1,145 @@
import io
import uuid
from PIL import Image
from app.storage import UPLOADS_DIR
from tests.conftest import register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _png_bytes(size: tuple[int, int] = (10, 10)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color=(255, 0, 0)).save(buf, format="PNG")
return buf.getvalue()
async def test_update_display_name_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"display_name": "Alice A."})
assert resp.status_code == 200, resp.text
assert resp.json()["display_name"] == "Alice A."
me = await client.get("/api/auth/me")
assert me.json()["display_name"] == "Alice A."
async def test_empty_display_name_clears_it(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"display_name": "Alice A."})
resp = await client.patch("/api/auth/me", json={"display_name": ""})
assert resp.status_code == 200
assert resp.json()["display_name"] is None
async def test_whitespace_display_name_clears_it(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch("/api/auth/me", json={"display_name": "Alice A."})
resp = await client.patch("/api/auth/me", json={"display_name": " "})
assert resp.status_code == 200
assert resp.json()["display_name"] is None
async def test_display_name_too_long_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch("/api/auth/me", json={"display_name": "x" * 51})
assert resp.status_code == 422
async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
)
assert resp.status_code == 200, resp.text
filename = resp.json()["avatar_filename"]
assert filename
me = await client.get("/api/auth/me")
assert me.json()["avatar_filename"] == filename
async def test_avatar_reupload_deletes_old_file(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
first = await client.post(
"/api/auth/me/avatar", files={"file": ("first.png", _png_bytes(), "image/png")}
)
first_filename = first.json()["avatar_filename"]
assert (UPLOADS_DIR / first_filename).exists()
second = await client.post(
"/api/auth/me/avatar", files={"file": ("second.png", _png_bytes((20, 20)), "image/png")}
)
second_filename = second.json()["avatar_filename"]
assert second_filename != first_filename
assert not (UPLOADS_DIR / first_filename).exists()
assert (UPLOADS_DIR / second_filename).exists()
async def test_avatar_oversized_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
oversized = b"0" * (9 * 1024 * 1024)
resp = await client.post(
"/api/auth/me/avatar", files={"file": ("huge.png", oversized, "image/png")}
)
assert resp.status_code == 413
async def test_avatar_non_image_rejected(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/auth/me/avatar", files={"file": ("fake.png", b"not an image", "image/png")}
)
assert resp.status_code == 400
async def test_avatar_visible_without_shared_room(client, db_session):
alice = await register_and_login(client, db_session, username=_unique("alice"))
await client.post(
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
)
# bob shares no room with alice at all -- unlike message images (room-
# gated), avatar visibility matches username visibility: any
# authenticated user can see it.
await register_and_login(client, db_session, username=_unique("bob"))
resp = await client.get(f"/api/users/{alice['id']}/avatar")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
async def test_remove_avatar_clears_and_404s(client, db_session):
alice = await register_and_login(client, db_session, username=_unique("alice"))
await client.post(
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
)
resp = await client.delete("/api/auth/me/avatar")
assert resp.status_code == 200
assert resp.json()["avatar_filename"] is None
avatar_resp = await client.get(f"/api/users/{alice['id']}/avatar")
assert avatar_resp.status_code == 404
async def test_room_members_include_avatar_filename(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
upload = await client.post(
"/api/auth/me/avatar", files={"file": ("test.png", _png_bytes(), "image/png")}
)
avatar_filename = upload.json()["avatar_filename"]
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
members = (await client.get(f"/api/rooms/{room['id']}/members")).json()
assert members[0]["avatar_filename"] == avatar_filename
+44 -1
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string {
return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}`
}
+11 -6
View File
@@ -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>
+18
View File
@@ -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;
}
+129
View File
@@ -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">
&times;
</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>
)
}
+8 -2
View File
@@ -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">
+3 -8
View File
@@ -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 {
+23 -3
View File
@@ -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>
)
}
+4
View File
@@ -8,3 +8,7 @@
color: var(--ds-text);
font-size: 0.68rem;
}
.user-avatar-img {
object-fit: cover;
}
+13 -1
View File
@@ -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"
+7 -1
View File
@@ -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>
)
+6
View File
@@ -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)
}
+14 -3
View File
@@ -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
}
+13 -1
View File
@@ -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'}`}>
+6
View File
@@ -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
}