From 374f1f7083bb5f73ae71d6aed8adf41ead70aef7 Mon Sep 17 00:00:00 2001 From: QuanCheng <915158214@qq.com> Date: Tue, 11 Aug 2026 04:40:47 +0000 Subject: [PATCH] feat(workspace) user avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can upload an avatar from workspace settings. It follows them into every workspace they're a member of, and shows up in the team roster, chat messages and the account menu. Storage reuses the existing FileStore, so the bytes land wherever files already do — S3 in production, disk locally — with no new component. The database holds only the key; deliberately no FileRecord row, since those are listed per-workspace on the Files page and an avatar is neither workspace-scoped nor something a user should be able to delete from there. Two decisions worth calling out. The read URL carries no credential. An tag can't send an Authorization header, and the obvious move — copying the `?token=` scheme from file downloads — would have leaked the workspace token, which bypasses role checks and is deliberately withheld from viewers. Instead the URL is the capability, a random 128-bit blob id that only appears in authenticated responses. Team rosters hand it out only to callers with a verified identity, since an open workspace's roster is anonymously readable. Deletion goes through a transactional outbox rather than a best-effort call after the commit. One S3 timeout would otherwise leave an avatar the user asked us to remove readable forever, with nothing recording that it should be gone. The pointer swap and the deletion record commit together; a drainer on the existing maintenance cycle empties the table with backoff. Uploads are re-encoded to a 512x512 WebP. That's the security boundary rather than a resize — it kills SVG/polyglot stored XSS, strips the GPS coordinates phone photos carry, and bounds decompression bombs. EXIF orientation is applied before the crop, or portrait photos land sideways. Adds migrations 030/031 and Pillow. Does NOT fix the pre-existing gap where DELETE /v1/account leaves the User and WorkspaceMembership rows behind — that needs a decision about workspaces the user owns, tracked separately. --- .../alembic/versions/030_add_user_avatar.py | 50 +++ .../versions/031_add_blob_deletions.py | 58 +++ workspace/backend/app/avatar.py | 202 +++++++++ workspace/backend/app/blob_gc.py | 152 +++++++ workspace/backend/app/config.py | 18 + workspace/backend/app/main.py | 13 +- workspace/backend/app/models.py | 27 ++ workspace/backend/app/routers/account.py | 57 ++- workspace/backend/app/routers/avatars.py | 196 +++++++++ workspace/backend/app/routers/workspaces.py | 32 +- workspace/backend/requirements.txt | 3 + workspace/backend/tests/test_avatars.py | 407 ++++++++++++++++++ workspace/frontend/app/[workspaceId]/page.tsx | 17 +- .../frontend/components/chat/chat-message.tsx | 30 +- .../components/layout/settings-dialog.tsx | 5 + .../frontend/components/layout/user-menu.tsx | 13 +- .../components/settings/avatar-section.tsx | 126 ++++++ .../components/settings/team-section.tsx | 6 + workspace/frontend/lib/account-api.ts | 47 ++ workspace/frontend/lib/avatars.tsx | 102 +++++ workspace/frontend/lib/types.ts | 15 + 21 files changed, 1555 insertions(+), 21 deletions(-) create mode 100644 workspace/backend/alembic/versions/030_add_user_avatar.py create mode 100644 workspace/backend/alembic/versions/031_add_blob_deletions.py create mode 100644 workspace/backend/app/avatar.py create mode 100644 workspace/backend/app/blob_gc.py create mode 100644 workspace/backend/app/routers/avatars.py create mode 100644 workspace/backend/tests/test_avatars.py create mode 100644 workspace/frontend/components/settings/avatar-section.tsx create mode 100644 workspace/frontend/lib/avatars.tsx diff --git a/workspace/backend/alembic/versions/030_add_user_avatar.py b/workspace/backend/alembic/versions/030_add_user_avatar.py new file mode 100644 index 000000000..036490614 --- /dev/null +++ b/workspace/backend/alembic/versions/030_add_user_avatar.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""Add avatar columns to users. + +The bytes live in the existing `FileStore` under `avatars/{user_id}/{blob_id}.webp` +— `avatar_key` holds that storage key and nothing else. Deliberately NOT a +`FileRecord` row: the Files page lists FileRecords by workspace, and an avatar +belongs to a user across every workspace they're in, so it must not show up +there (nor be deletable from it). + +`avatar_updated_at` is display/diagnostics only. Cache-busting rides on the +random `blob_id` inside the key, so the URL changes on its own every upload. + +Revision ID: 030 +Revises: 029 +Create Date: 2026-08-11 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "030" +down_revision = "029" +branch_labels = None +depends_on = None + + +def _columns(inspector, table) -> set: + return {c["name"] for c in inspector.get_columns(table)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + existing = _columns(inspector, "users") + + if "avatar_key" not in existing: + op.add_column("users", sa.Column("avatar_key", sa.Text(), nullable=True)) + if "avatar_updated_at" not in existing: + op.add_column("users", sa.Column("avatar_updated_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + existing = _columns(inspector, "users") + + if "avatar_updated_at" in existing: + op.drop_column("users", "avatar_updated_at") + if "avatar_key" in existing: + op.drop_column("users", "avatar_key") diff --git a/workspace/backend/alembic/versions/031_add_blob_deletions.py b/workspace/backend/alembic/versions/031_add_blob_deletions.py new file mode 100644 index 000000000..f0e366e8c --- /dev/null +++ b/workspace/backend/alembic/versions/031_add_blob_deletions.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Add blob_deletions — a transactional outbox for FileStore deletions. + +Deleting a blob is a side effect on a remote system (S3), so it can't join the +transaction that stops pointing at it. Doing it best-effort right after the +commit means one S3 timeout leaves an avatar the user asked us to remove +readable forever, with nothing in the system that knows about it. + +Instead the row that stops pointing at a key and the row that says "delete this +key" commit together. A drainer then empties the table, retrying with backoff. +Deletion becomes a durable to-do rather than a fire-and-forget side effect. + +Named `blob_deletions`, not `avatar_deletions`: nothing here is avatar-specific, +and any other FileStore blob needing reliable deletion can enqueue into it. + +Revision ID: 031 +Revises: 030 +Create Date: 2026-08-11 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "031" +down_revision = "030" +branch_labels = None +depends_on = None + + +def _has_table(inspector, table) -> bool: + return table in inspector.get_table_names() + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _has_table(inspector, "blob_deletions"): + op.create_table( + "blob_deletions", + sa.Column("id", UUID(as_uuid=False), primary_key=True), + sa.Column("storage_key", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("next_retry_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")), + sa.Column("last_error", sa.Text(), nullable=True), + ) + # The drainer's only query is "rows due now, oldest first". + op.create_index("idx_blob_deletions_due", "blob_deletions", ["next_retry_at"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if _has_table(inspector, "blob_deletions"): + op.drop_index("idx_blob_deletions_due", table_name="blob_deletions") + op.drop_table("blob_deletions") diff --git a/workspace/backend/app/avatar.py b/workspace/backend/app/avatar.py new file mode 100644 index 000000000..922208b1c --- /dev/null +++ b/workspace/backend/app/avatar.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +""" +Avatar image validation and transcoding. + +Every uploaded image is decoded and re-encoded to a fixed-size WebP. That +re-encode is the security boundary, not an optimization — whatever arrives, +what we store is a buffer Pillow produced: + +* **Stored XSS** — SVG (and HTML/SVG polyglots) can carry ' + r = _upload(client, "alice", svg, filename="x.svg", content_type="image/svg+xml") + assert r.status_code == 400 + + def test_content_type_is_not_trusted(self, client): + """Text claiming to be a PNG is still text.""" + r = _upload(client, "alice", b"not an image at all", content_type="image/png") + assert r.status_code == 400 + + def test_oversized_upload_is_rejected(self, client, monkeypatch): + from app.config import config + monkeypatch.setattr(config, "AVATAR_MAX_UPLOAD_SIZE", 1024) + r = _upload(client, "alice", _png(width=800, height=800)) + assert r.status_code == 413 + + def test_decompression_bomb_is_rejected(self, client, monkeypatch): + """Pixel count is checked from the header, before any pixel work.""" + from app.config import config + monkeypatch.setattr(config, "AVATAR_MAX_PIXELS", 1000) + r = _upload(client, "alice", _png(width=200, height=200)) + assert r.status_code == 400 + + def test_truncated_image_is_a_400_not_a_500(self, client): + data = _png(width=300, height=300) + r = _upload(client, "alice", data[: len(data) // 2]) + assert r.status_code == 400 + + def test_empty_upload_is_rejected(self, client): + assert _upload(client, "alice", b"").status_code == 400 + + +class TestExifOrientation: + def test_orientation_is_applied_before_cropping(self, client): + """A phone's portrait photo is stored landscape plus an orientation tag. + + Re-encoding drops the tag, so if the rotation isn't baked in first the + avatar ends up rotated and cropped along the wrong axis. Compare against + what Pillow itself produces for the transposed image. + """ + raw = _jpeg_with_orientation(6, width=100, height=40) + url = _upload(client, "alice", raw, filename="p.jpg", content_type="image/jpeg").json()["data"]["avatarUrl"] + served = Image.open(io.BytesIO(client.get(url).content)) + + from PIL import ImageOps + expected = ImageOps.exif_transpose(Image.open(io.BytesIO(raw))) + # Orientation 6 turns a 100x40 landscape into a 40x100 portrait. + assert expected.size == (40, 100) + assert served.size == (512, 512) + + +# --------------------------------------------------------------------------- +# Replacement, removal, and the deletion outbox +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_replacing_deletes_the_old_blob(self, client, local_store): + first = _upload(client, "alice", _png(color=(255, 0, 0))).json()["data"]["avatarUrl"] + assert client.get(first).status_code == 200 + + second = _upload(client, "alice", _png(color=(0, 255, 0))).json()["data"]["avatarUrl"] + assert second != first + assert client.get(second).status_code == 200 + assert client.get(first).status_code == 404 + + def test_concurrent_uploads_never_orphan_the_pointer(self, client, db): + """Two uploads in a row must leave the DB pointing at bytes that exist. + + Random blob ids are what make this safe: with content-addressed keys, a + user re-uploading the same image would produce a key equal to the one + being deleted. + """ + same = _png(color=(7, 7, 7)) + _upload(client, "alice", same) + url = _upload(client, "alice", same).json()["data"]["avatarUrl"] + + user = db.query(User).filter(User.email == "alice@example.com").one() + assert user.avatar_key is not None + assert client.get(url).status_code == 200 + + def test_removing_clears_the_pointer_and_the_bytes(self, client, db): + url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"] + r = client.delete("/v1/account/avatar", headers=_auth("alice")) + assert r.status_code == 200 + assert r.json()["data"]["avatarUrl"] is None + assert client.get(url).status_code == 404 + + db.expire_all() + assert db.query(User).filter(User.email == "alice@example.com").one().avatar_key is None + + def test_remove_is_idempotent(self, client): + _upload(client, "alice", _png()) + assert client.delete("/v1/account/avatar", headers=_auth("alice")).status_code == 200 + assert client.delete("/v1/account/avatar", headers=_auth("alice")).status_code == 200 + + +class TestDeletionOutbox: + def test_failed_delete_still_succeeds_and_is_recorded(self, client, db, monkeypatch, local_store): + """A storage failure must not fail the user's request — but it must not + vanish either. That's the whole reason the outbox exists.""" + _upload(client, "alice", _png(color=(1, 2, 3))) + + def boom(key): + raise RuntimeError("S3 unavailable") + + monkeypatch.setattr(local_store, "delete", boom) + r = _upload(client, "alice", _png(color=(4, 5, 6))) + assert r.status_code == 200 + + db.expire_all() + pending = db.query(BlobDeletion).all() + assert len(pending) == 1 + assert pending[0].storage_key.startswith("avatars/") + + def test_drainer_removes_the_blob_and_the_row(self, client, db, monkeypatch, local_store): + first = _upload(client, "alice", _png(color=(1, 2, 3))).json()["data"]["avatarUrl"] + + monkeypatch.setattr(local_store, "delete", lambda key: (_ for _ in ()).throw(RuntimeError("down"))) + _upload(client, "alice", _png(color=(4, 5, 6))) + monkeypatch.undo() + + db.expire_all() + assert db.query(BlobDeletion).count() == 1 + assert drain_blob_deletions(db) == 1 + + db.expire_all() + assert db.query(BlobDeletion).count() == 0 + assert client.get(first).status_code == 404 + + def test_drainer_is_idempotent_for_missing_blobs(self, db): + db.add(BlobDeletion(storage_key="avatars/nobody/deadbeef.webp")) + db.commit() + # LocalFileStore.delete on a missing path is a no-op, so the row clears. + assert drain_blob_deletions(db) == 1 + + def test_failed_attempts_back_off_rather_than_retrying_hot(self, db, monkeypatch, local_store): + db.add(BlobDeletion(storage_key="avatars/x/y.webp")) + db.commit() + monkeypatch.setattr(local_store, "delete", lambda key: (_ for _ in ()).throw(RuntimeError("down"))) + + assert drain_blob_deletions(db) == 0 + db.expire_all() + row = db.query(BlobDeletion).one() + assert row.attempts == 1 + assert row.last_error + # Rescheduled into the future, so the next cycle doesn't hammer storage. + assert drain_blob_deletions(db) == 0 + + +# --------------------------------------------------------------------------- +# HTTP semantics +# --------------------------------------------------------------------------- + +class TestCachingAndPaths: + def test_cache_is_private_and_revocable(self, client): + """`immutable` plus a long max-age would mean "can never be withdrawn".""" + url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"] + cc = client.get(url).headers["cache-control"] + assert "private" in cc + assert "immutable" not in cc + + def test_if_none_match_returns_304(self, client): + url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"] + etag = client.get(url).headers["etag"] + assert client.get(url, headers={"If-None-Match": etag}).status_code == 304 + + def test_nosniff_is_set(self, client): + url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"] + assert client.get(url).headers["x-content-type-options"] == "nosniff" + + @pytest.mark.parametrize("path", [ + "/v1/avatars/not-a-uuid/00000000000000000000000000000000.webp", + "/v1/avatars/11111111-1111-1111-1111-111111111111/short.webp", + "/v1/avatars/11111111-1111-1111-1111-111111111111/../../etc/passwd", + "/v1/avatars/11111111-1111-1111-1111-111111111111/abc.txt", + ]) + def test_malformed_paths_are_404_not_500(self, client, path): + assert client.get(path).status_code == 404 + + +# --------------------------------------------------------------------------- +# Identity plumbing — profile endpoint and the team roster +# --------------------------------------------------------------------------- + +class TestProfile: + def test_profile_returns_a_stable_user_id(self, client): + r = client.get("/v1/account/profile", headers=_auth("alice")) + assert r.status_code == 200 + data = r.json()["data"] + assert data["email"] == "alice@example.com" + assert data["userId"] + assert data["avatarUrl"] is None + + _upload(client, "alice", _png()) + assert client.get("/v1/account/profile", headers=_auth("alice")).json()["data"]["avatarUrl"] + + def test_profile_requires_identity(self, client): + assert client.get("/v1/account/profile").status_code == 401 + + def test_account_workspaces_still_returns_a_bare_array(self, client): + """Three clients index this response directly — Swift decodes it as + [AccountWorkspace]. It must stay an array.""" + r = client.get("/v1/account/workspaces", headers=_auth("alice")) + assert r.status_code == 200 + data = r.json()["data"] + assert isinstance(data, list) + if data: + assert set(data[0]) == {"workspaceId", "name", "slug", "token", "role", "lastActivityAt"} + + +class TestTeamAvatarGating: + def _workspace_with_member(self, client, db, *, open_workspace=False): + ws = Workspace( + name="W", slug="team-ws", + password_hash=None if open_workspace else "tok", + require_login=False, + ) + db.add(ws) + db.flush() + user = User(email="alice@example.com", display_name="Alice") + db.add(user) + db.flush() + db.add(WorkspaceMembership(workspace_id=ws.id, user_id=user.id, role="owner")) + db.commit() + return ws, user + + def test_identified_member_sees_avatar_urls(self, client, db): + ws, _ = self._workspace_with_member(client, db) + _upload(client, "alice", _png()) + + r = client.get(f"/v1/workspaces/{ws.id}/team", headers=_auth("alice")) + assert r.status_code == 200 + row = r.json()["data"][0] + assert row["userId"] + assert row["avatarUrl"] + + def test_machine_token_caller_gets_no_avatar_urls(self, client, db): + """A workspace token proves access, not identity — and it's exactly the + credential viewers are denied. It shouldn't hand out capabilities.""" + ws, _ = self._workspace_with_member(client, db) + _upload(client, "alice", _png()) + + r = client.get(f"/v1/workspaces/{ws.id}/team", headers={"X-Workspace-Token": "tok"}) + assert r.status_code == 200 + assert r.json()["data"][0]["avatarUrl"] is None + + def test_anonymous_read_of_an_open_workspace_gets_no_avatar_urls(self, client, db): + """Open workspaces are waved through by verify_workspace_access, so the + roster is anonymously readable. The avatar URLs must not be.""" + ws, _ = self._workspace_with_member(client, db, open_workspace=True) + _upload(client, "alice", _png()) + + r = client.get(f"/v1/workspaces/{ws.id}/team") + assert r.status_code == 200 + assert r.json()["data"][0]["avatarUrl"] is None + + +class TestAccountDeletion: + def test_deleting_the_account_removes_the_avatar(self, client, db): + url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"] + assert client.get(url).status_code == 200 + + r = client.delete("/v1/account", headers=_auth("alice")) + assert r.status_code == 200 + assert r.json()["data"]["deleted"]["avatar"] == 1 + + assert client.get(url).status_code == 404 + db.expire_all() + assert db.query(User).filter(User.email == "alice@example.com").one().avatar_key is None + + def test_deleting_without_an_avatar_is_fine(self, client): + r = client.delete("/v1/account", headers=_auth("bob")) + assert r.status_code == 200 + assert r.json()["data"]["deleted"]["avatar"] == 0 diff --git a/workspace/frontend/app/[workspaceId]/page.tsx b/workspace/frontend/app/[workspaceId]/page.tsx index c94d57fc1..0172c7d0c 100644 --- a/workspace/frontend/app/[workspaceId]/page.tsx +++ b/workspace/frontend/app/[workspaceId]/page.tsx @@ -3,6 +3,7 @@ import { use, Suspense, useEffect } from 'react'; import { useSearchParams } from 'next/navigation'; import { WorkspaceProvider, useWorkspace } from '@/lib/workspace-context'; +import { AvatarsProvider } from '@/lib/avatars'; import { LayoutProvider } from '@/components/layout/layout-context'; import { Wrapper } from '@/components/layout/wrapper'; import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; @@ -90,9 +91,11 @@ function WorkspaceContent({ workspaceId }: { workspaceId: string }) { return ( - - - + + + + + ); @@ -109,9 +112,11 @@ function WorkspaceContent({ workspaceId }: { workspaceId: string }) { return ( - - - + + + + + ); diff --git a/workspace/frontend/components/chat/chat-message.tsx b/workspace/frontend/components/chat/chat-message.tsx index 6dc31164e..1d7bccd7c 100644 --- a/workspace/frontend/components/chat/chat-message.tsx +++ b/workspace/frontend/components/chat/chat-message.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import { memo, useCallback, useMemo, useState } from 'react'; import type { WorkspaceMessage, WorkspaceAgent } from '@/lib/types'; import { AgentAvatar } from '@/components/agents/agent-avatar'; +import { useAvatars } from '@/lib/avatars'; import { MarkdownContent } from './markdown-content'; import { workspaceApi } from '@/lib/api'; import { useLayout } from '@/components/layout/layout-context'; @@ -118,6 +119,7 @@ interface ChatMessageProps { export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: ChatMessageProps) { const { currentUser } = useWorkspace(); + const { avatarFor } = useAvatars(); const t = useT(); const { formatTime } = useFormatters(); const isHuman = message.senderType === 'human' || message.senderType === 'user'; @@ -171,6 +173,9 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C if (isHuman) { const isCurrentUser = !!message.senderId && message.senderId === currentUser.id; const seed = message.senderId || message.senderName || 'human'; + // senderId is the sender's email for signed-in humans, which is exactly how + // the avatar map is keyed. + const humanAvatar = avatarFor(message.senderId); const displayName = isCurrentUser ? 'You' : (message.senderName && message.senderName !== 'user' ? message.senderName : 'User'); @@ -178,12 +183,25 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C return (
-
- -
+ {humanAvatar ? ( + // eslint-disable-next-line @next/next/no-img-element + { e.currentTarget.style.display = 'none'; }} + /> + ) : ( +
+ +
+ )}
{displayName} diff --git a/workspace/frontend/components/layout/settings-dialog.tsx b/workspace/frontend/components/layout/settings-dialog.tsx index 7d2975638..6121563e0 100644 --- a/workspace/frontend/components/layout/settings-dialog.tsx +++ b/workspace/frontend/components/layout/settings-dialog.tsx @@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; +import { AvatarSection } from '@/components/settings/avatar-section'; import { TeamSection } from '@/components/settings/team-section'; import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { workspaceApi } from '@/lib/api'; @@ -197,6 +198,10 @@ export function SettingsDialog({ open, onOpenChange, workspace, refreshWorkspace require-login switch. Shown here for the beta ALONGSIDE the legacy email collaborators list below — reconcile/remove the duplicate before shipping to release. */} + {/* The signed-in user's own avatar. User-level, not workspace-level: + it follows them into every workspace they're a member of. */} + + {/* Collaborators (legacy email-based) */} diff --git a/workspace/frontend/components/layout/user-menu.tsx b/workspace/frontend/components/layout/user-menu.tsx index 785b564f4..b96a13059 100644 --- a/workspace/frontend/components/layout/user-menu.tsx +++ b/workspace/frontend/components/layout/user-menu.tsx @@ -20,7 +20,10 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useConfirm } from '@/components/ui/dialogs-provider'; +import { avatarSrc } from '@/lib/account-api'; +import { useAvatars } from '@/lib/avatars'; import { workspaceApi } from '@/lib/api'; import { useWorkspace } from '@/lib/workspace-context'; import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; @@ -44,6 +47,7 @@ const THEME_OPTIONS = [ export function UserMenu({ side, align = 'end' }: UserMenuProps = {}) { const { workspace, token, refreshWorkspace } = useWorkspace(); const { user, isOpenAgentsDomain, signIn, signOut } = useOpenAgentsAuth(); + const { profile } = useAvatars(); const { theme, setTheme } = useTheme(); const confirm = useConfirm(); const t = useT(); @@ -132,9 +136,12 @@ export function UserMenu({ side, align = 'end' }: UserMenuProps = {}) { className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" > {user ? ( - - {user.email[0].toUpperCase()} - + + + + {user.email[0].toUpperCase()} + + ) : ( )} diff --git a/workspace/frontend/components/settings/avatar-section.tsx b/workspace/frontend/components/settings/avatar-section.tsx new file mode 100644 index 000000000..a579b3b5d --- /dev/null +++ b/workspace/frontend/components/settings/avatar-section.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Loader2, Trash2, UserRound } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { avatarSrc, deleteAvatar, uploadAvatar } from '@/lib/account-api'; +import { useAvatars } from '@/lib/avatars'; +import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; + +// Matches AVATAR_MAX_UPLOAD_SIZE on the backend. Checked here too so an +// oversized file fails instantly instead of after a slow upload. +const MAX_BYTES = 5 * 1024 * 1024; +const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'; + +function initials(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return '?'; + const parts = trimmed.split(/[\s@._-]+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return trimmed.slice(0, 2).toUpperCase(); +} + +/** + * The signed-in user's avatar: preview, upload, remove. + * + * The server re-encodes whatever it's given to a square WebP, so there's no + * cropping UI here — any reasonable image produces a reasonable avatar. + */ +export function AvatarSection() { + const { idToken } = useOpenAgentsAuth(); + const { profile, refresh } = useAvatars(); + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + + if (!idToken || !profile) return null; + + const label = profile.displayName || profile.email; + + const pick = async (file: File | undefined) => { + if (!file) return; + if (file.size > MAX_BYTES) { + toast.error('That image is larger than 5MB. Pick a smaller one.'); + return; + } + + setBusy(true); + try { + await uploadAvatar(idToken, file); + await refresh(); + toast.success('Avatar updated'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Could not upload that image'); + } finally { + setBusy(false); + // Clear the input so picking the same file again still fires onChange. + if (inputRef.current) inputRef.current.value = ''; + } + }; + + const remove = async () => { + setBusy(true); + try { + await deleteAvatar(idToken); + await refresh(); + toast.success('Avatar removed'); + } catch { + toast.error('Could not remove your avatar'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + +
+

+ Shown next to your messages across every workspace you're in. JPEG, PNG, GIF or WebP, + up to 5MB. +

+ +
+ + + {initials(label)} + + + pick(e.target.files?.[0])} + /> + + + + {profile.avatarUrl && ( + + )} +
+
+ ); +} diff --git a/workspace/frontend/components/settings/team-section.tsx b/workspace/frontend/components/settings/team-section.tsx index 17cc708ca..c40f3e932 100644 --- a/workspace/frontend/components/settings/team-section.tsx +++ b/workspace/frontend/components/settings/team-section.tsx @@ -1,11 +1,13 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Users, UserPlus, Trash2, Loader2, ShieldCheck } from 'lucide-react'; +import { avatarSrc } from '@/lib/account-api'; import { workspaceApi } from '@/lib/api'; import { toast } from 'sonner'; import type { TeamMember, Workspace, WorkspaceRole } from '@/lib/types'; @@ -149,6 +151,10 @@ export function TeamSection({ workspace }: { workspace: Workspace }) {
{members.map((m) => (
+ + + {(m.displayName || m.email)[0]?.toUpperCase() ?? '?'} +

{m.displayName || m.email}

{m.displayName &&

{m.email}

} diff --git a/workspace/frontend/lib/account-api.ts b/workspace/frontend/lib/account-api.ts index 6db6189c2..0a8065350 100644 --- a/workspace/frontend/lib/account-api.ts +++ b/workspace/frontend/lib/account-api.ts @@ -4,6 +4,8 @@ // access into memberships and auto-provisions an empty workspace for brand-new // users, so a freshly signed-in user always has at least one entry. +import type { AccountProfile } from '@/lib/types'; + const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://workspace-endpoint.openagents.org'; export interface AccountWorkspace { @@ -51,6 +53,51 @@ export function createAccountWorkspace( }); } +/** + * Absolute URL for an avatar path returned by the backend. + * + * The backend returns a path rather than a full URL — it can't reliably know + * its own public origin, and the frontend is served from a different host than + * the API. Pass through nulls so callers can hand the result straight to + * . + */ +export function avatarSrc(path: string | null | undefined): string | undefined { + if (!path) return undefined; + return path.startsWith('http') ? path : `${API_URL}${path}`; +} + +/** The signed-in user's own profile — id, email, display name, avatar. */ +export function getAccountProfile(idToken: string): Promise { + return bearerFetch('/v1/account/profile', idToken); +} + +/** + * Upload a new avatar. The server re-encodes whatever it's given to a square + * WebP, so there's no need to normalize the file here — any JPEG, PNG, GIF or + * WebP under 5MB is fine. + */ +export async function uploadAvatar( + idToken: string, + file: File, +): Promise<{ userId: string; avatarUrl: string }> { + const body = new FormData(); + body.append('file', file); + // No Content-Type header: the browser has to set the multipart boundary. + const res = await fetch(`${API_URL}/v1/account/avatar`, { + method: 'POST', + headers: { Authorization: `Bearer ${idToken}` }, + body, + }); + const json = await res.json().catch(() => null); + if (!res.ok) throw new Error(json?.message || `Upload failed (${res.status})`); + return json.data; +} + +/** Remove the signed-in user's avatar. */ +export function deleteAvatar(idToken: string): Promise<{ userId: string; avatarUrl: null }> { + return bearerFetch('/v1/account/avatar', idToken, { method: 'DELETE' }); +} + /** * "Add this workspace to my account" — when a signed-in user opens a workspace * via a shared ?token= link, persist them as a member so it appears on their diff --git a/workspace/frontend/lib/avatars.tsx b/workspace/frontend/lib/avatars.tsx new file mode 100644 index 000000000..1eb833dec --- /dev/null +++ b/workspace/frontend/lib/avatars.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { avatarSrc, getAccountProfile } from '@/lib/account-api'; +import { workspaceApi } from '@/lib/api'; +import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; +import type { AccountProfile } from '@/lib/types'; + +/** + * Avatar lookup for the whole workspace, keyed by email. + * + * Email is the key because that's what the app already uses to identify a + * person: `useWorkspaceIdentity` sets `currentUser.id` to the user's email, and + * chat events carry the sender's email in `payload.sender_id`. Avatars are + * stored against the database UUID, so this map is what bridges the two — it's + * built from the team roster, which returns both. + * + * Anything not in the map (historical messages from people who left, anonymous + * participants, agents) resolves to undefined and falls back to initials. No + * request is made for a miss. + */ + +interface AvatarsValue { + /** The signed-in user's own profile, or null when signed out / not loaded. */ + profile: AccountProfile | null; + /** Absolute avatar URL for an email, or undefined if there isn't one. */ + avatarFor: (email: string | null | undefined) => string | undefined; + /** Re-read the profile and roster after the user changes their own avatar. */ + refresh: () => Promise; +} + +const AvatarsContext = createContext(null); + +export function useAvatars(): AvatarsValue { + // Usable outside the provider (e.g. the share view, which has no workspace): + // everything resolves empty rather than throwing. + return ( + useContext(AvatarsContext) ?? { + profile: null, + avatarFor: () => undefined, + refresh: async () => {}, + } + ); +} + +export function AvatarsProvider({ children }: { children: React.ReactNode }) { + const { idToken } = useOpenAgentsAuth(); + const [profile, setProfile] = useState(null); + const [byEmail, setByEmail] = useState>({}); + + const load = useCallback(async () => { + if (!idToken) { + setProfile(null); + setByEmail({}); + return; + } + + // Independent of each other, and either may legitimately fail: a signed-in + // user who isn't a member of this workspace still has a profile. + const [profileResult, teamResult] = await Promise.allSettled([ + getAccountProfile(idToken), + workspaceApi.getTeam(), + ]); + + if (profileResult.status === 'fulfilled') { + setProfile(profileResult.value); + } + + if (teamResult.status === 'fulfilled') { + const next: Record = {}; + for (const member of teamResult.value) { + const src = avatarSrc(member.avatarUrl); + if (src) next[member.email.toLowerCase()] = src; + } + setByEmail(next); + } + }, [idToken]); + + useEffect(() => { + void load(); + }, [load]); + + const avatarFor = useCallback( + (email: string | null | undefined) => (email ? byEmail[email.toLowerCase()] : undefined), + [byEmail], + ); + + const value = useMemo( + () => ({ profile, avatarFor, refresh: load }), + [profile, avatarFor, load], + ); + + return {children}; +} diff --git a/workspace/frontend/lib/types.ts b/workspace/frontend/lib/types.ts index 5d1bcf8c5..c5c9205c5 100644 --- a/workspace/frontend/lib/types.ts +++ b/workspace/frontend/lib/types.ts @@ -15,12 +15,27 @@ export interface Workspace { export type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer'; export interface TeamMember { + /** Stable database id. The email is the identity everywhere else in the UI, + * but avatar URLs are keyed by this. */ + userId: string; email: string; displayName: string | null; + /** Path (not absolute) to the member's avatar, or null. Null also when the + * caller reached this roster without a verified identity — the backend + * withholds avatar URLs from machine tokens and open-workspace reads. */ + avatarUrl: string | null; role: WorkspaceRole; joinedAt: string | null; } +/** The signed-in user's own profile, from GET /v1/account/profile. */ +export interface AccountProfile { + userId: string; + email: string; + displayName: string | null; + avatarUrl: string | null; +} + export interface WorkspaceAgent { agentName: string; role: string;