From 8ca3e2e23d113599963912959f2017f656abdeca Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 14 Aug 2026 16:59:56 -0600 Subject: [PATCH] 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. --- backend/README.md | 53 ++++++- .../f6e024985d4d_user_profile_fields.py | 36 +++++ backend/app/main.py | 3 +- backend/app/models/user.py | 3 + backend/app/routers/auth.py | 79 +++++++++- backend/app/routers/invites.py | 2 + backend/app/routers/rooms.py | 9 +- backend/app/routers/users.py | 34 ++++ backend/app/schemas/admin.py | 2 + backend/app/schemas/room.py | 2 + backend/app/schemas/user.py | 6 + backend/app/storage.py | 28 +++- backend/tests/test_profile.py | 145 ++++++++++++++++++ frontend/src/api/auth.ts | 45 +++++- frontend/src/api/users.ts | 3 + frontend/src/components/MessageList.tsx | 17 +- frontend/src/components/Modal.css | 18 +++ frontend/src/components/ProfileModal.tsx | 129 ++++++++++++++++ frontend/src/components/RoomInfoPanel.tsx | 10 +- frontend/src/components/TopBar.css | 11 +- frontend/src/components/TopBar.tsx | 26 +++- frontend/src/components/UserAvatar.css | 4 + frontend/src/components/UserAvatar.tsx | 14 +- frontend/src/context/AuthContext.tsx | 8 +- frontend/src/lib/avatar.ts | 6 + frontend/src/lib/messageGrouping.ts | 17 +- frontend/src/pages/AdminPage.tsx | 14 +- frontend/src/types.ts | 6 + 28 files changed, 689 insertions(+), 41 deletions(-) create mode 100644 backend/alembic/versions/f6e024985d4d_user_profile_fields.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/tests/test_profile.py create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/components/ProfileModal.tsx diff --git a/backend/README.md b/backend/README.md index 2806242..d84a58b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/alembic/versions/f6e024985d4d_user_profile_fields.py b/backend/alembic/versions/f6e024985d4d_user_profile_fields.py new file mode 100644 index 0000000..9f3b2c1 --- /dev/null +++ b/backend/alembic/versions/f6e024985d4d_user_profile_fields.py @@ -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 ### diff --git a/backend/app/main.py b/backend/app/main.py index 0fb6119..01b5e2c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 395f1b7..6f2c7d9 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 93d62a9..f7649eb 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/invites.py b/backend/app/routers/invites.py index 03fd4f3..8e8b656 100644 --- a/backend/app/routers/invites.py +++ b/backend/app/routers/invites.py @@ -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, ) diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index dac82dd..d9bb861 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -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, ) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..5cd0499 --- /dev/null +++ b/backend/app/routers/users.py @@ -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"}, + ) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 7cd16a6..0cc7597 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -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 diff --git a/backend/app/schemas/room.py b/backend/app/schemas/room.py index afcd403..083d4cc 100644 --- a/backend/app/schemas/room.py +++ b/backend/app/schemas/room.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index bbfd115..8e0d7aa 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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) diff --git a/backend/app/storage.py b/backend/app/storage.py index 74dc78a..49e7256 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -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) diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..048eb08 --- /dev/null +++ b/backend/tests/test_profile.py @@ -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 diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index d180cd5..3e2385f 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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 { export function me(): Promise { return apiFetch('/api/auth/me') } + +export function updateProfile(displayName: string | null): Promise { + return apiFetch('/api/auth/me', { + method: 'PATCH', + body: JSON.stringify({ display_name: displayName }), + }) +} + +export function removeAvatar(): Promise { + return apiFetch('/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 { + 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 +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..dd0df15 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,3 @@ +export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string { + return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}` +} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 4095feb..3300b40 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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(null) const [reactingId, setReactingId] = useState(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
{isGroupStart && ( - + )}
{isGroupStart && (
- {msg.username} + {displayNameFor(msg.username, members)} {new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} @@ -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)} > {r.emoji} diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index f67f85e..d4cdea5 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -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; +} diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx new file mode 100644 index 0000000..17ff922 --- /dev/null +++ b/frontend/src/components/ProfileModal.tsx @@ -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(null) + const [savingName, setSavingName] = useState(false) + const [uploadingAvatar, setUploadingAvatar] = useState(false) + const fileInputRef = useRef(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) { + 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 ( +
+
e.stopPropagation()}> +
+

Profile settings

+ +
+ +
+ +
+ + + {avatarUrl && ( + + )} +
+
+ +
+
Display name
+ setDisplayName(e.target.value)} + placeholder={user.username} + maxLength={50} + /> + {error &&

{error}

} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/RoomInfoPanel.tsx b/frontend/src/components/RoomInfoPanel.tsx index 06032ef..c9b84dc 100644 --- a/frontend/src/components/RoomInfoPanel.tsx +++ b/frontend/src/components/RoomInfoPanel.tsx @@ -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({
Members
{members.map((m, i) => (
- - {m.username} + + {m.display_name || m.username} {m.role} {myRole === 'owner' && m.user_id !== user?.id && (
diff --git a/frontend/src/components/TopBar.css b/frontend/src/components/TopBar.css index 0b2ac0e..e11b3eb 100644 --- a/frontend/src/components/TopBar.css +++ b/frontend/src/components/TopBar.css @@ -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 { diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 56d0851..68e7b66 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -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(null) @@ -53,13 +57,28 @@ export function TopBar() { aria-expanded={menuOpen} aria-label="Account menu" > - {initials(user.username)} + {menuOpen && ( <>
setMenuOpen(false)} />
-
{user.username}
+
{user.display_name || user.username}
+ {user.is_site_admin && (
+ {profileModalOpen && setProfileModalOpen(false)} />} ) } diff --git a/frontend/src/components/UserAvatar.css b/frontend/src/components/UserAvatar.css index f512e15..aac4ab3 100644 --- a/frontend/src/components/UserAvatar.css +++ b/frontend/src/components/UserAvatar.css @@ -8,3 +8,7 @@ color: var(--ds-text); font-size: 0.68rem; } + +.user-avatar-img { + object-fit: cover; +} diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx index 92081fa..b0dd542 100644 --- a/frontend/src/components/UserAvatar.tsx +++ b/frontend/src/components/UserAvatar.tsx @@ -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 ( + + ) + } + return (
Promise logout: () => Promise + updateUser: (user: User) => void } const AuthContext = createContext(undefined) @@ -67,8 +68,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { clearLastUser() } + function updateUser(u: User) { + setUser(u) + saveLastUser(u) + } + return ( - + {children} ) diff --git a/frontend/src/lib/avatar.ts b/frontend/src/lib/avatar.ts index 8a0a81a..0520aeb 100644 --- a/frontend/src/lib/avatar.ts +++ b/frontend/src/lib/avatar.ts @@ -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) +} diff --git a/frontend/src/lib/messageGrouping.ts b/frontend/src/lib/messageGrouping.ts index d777dbd..8b520d5 100644 --- a/frontend/src/lib/messageGrouping.ts +++ b/frontend/src/lib/messageGrouping.ts @@ -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 } diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 523d6d7..06ffba4 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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() { + @@ -253,7 +257,15 @@ export function AdminPage() { {users.map((u) => ( - + +
Username Email Status
{u.username} + + {u.display_name || u.username} {u.email} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9f85bf4..d8a6330 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 }