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;