diff --git a/src/app/__tests__/endorsements-received-rejected.test.tsx b/src/app/__tests__/endorsements-received-rejected.test.tsx index 27ca994a..63ed8dc5 100644 --- a/src/app/__tests__/endorsements-received-rejected.test.tsx +++ b/src/app/__tests__/endorsements-received-rejected.test.tsx @@ -31,6 +31,11 @@ vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ did: DID, isAuthenticated: true }), })) +// Personal (non-delegated) session — the page renders its owner inbox. +vi.mock("@/lib/groups/org-context", () => ({ + useOrg: () => ({ activeOrg: null }), +})) + vi.mock("@/lib/navbar-context", () => ({ usePageTitle: () => undefined, })) diff --git a/src/app/endorsements/page.tsx b/src/app/endorsements/page.tsx index b097b112..62a9d7a8 100644 --- a/src/app/endorsements/page.tsx +++ b/src/app/endorsements/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { usePathname, useRouter, useSearchParams } from "next/navigation" import { Plus } from "lucide-react" import { useAuth } from "@/lib/auth/auth-context" +import { useOrg } from "@/lib/groups/org-context" import { usePageTitle } from "@/lib/navbar-context" import { useGivenEndorsements } from "@/hooks/use-endorsements" import { useReceivedEndorsements, type ReceivedEndorsement } from "@/hooks/use-received-endorsements" @@ -208,6 +209,7 @@ const DEFAULT_TAB: TabKey = "given" export default function EndorsementsPage() { usePageTitle("Endorsements") const { did } = useAuth() + const { activeOrg } = useOrg() // Tab state lives in `?tab=` on the URL so refresh / shared // link lands on the same view. The default tab stays bare to keep @@ -215,6 +217,15 @@ export default function EndorsementsPage() { const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() + + // /endorsements manages your PERSONAL given/received endorsements. While + // delegated (acting as a group) it's hidden from nav; if reached by a + // direct URL, bounce to /home so you can't create/revoke/respond on your + // personal repo while "being" the org. The org's endorsements live on the + // org's own profile. + useEffect(() => { + if (activeOrg) router.replace("/home") + }, [activeOrg, router]) const tabFromUrl = useMemo(() => { const v = searchParams?.get("tab") return v && TABS.some((t) => t.key === v) ? (v as TabKey) : DEFAULT_TAB diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index 47086570..629a2e66 100644 --- a/src/components/notifications/notification-row.tsx +++ b/src/components/notifications/notification-row.tsx @@ -14,6 +14,7 @@ import { getInitials } from "@/lib/utils/initials" import Avatar from "@/components/ui/avatar" import ResponseButtons from "@/components/badges/response-buttons" import { useAuth } from "@/lib/auth/auth-context" +import { useOrg } from "@/lib/groups/org-context" function reasonText( notification: Notification, @@ -43,6 +44,10 @@ interface NotificationRowProps { export default function NotificationRow({ notification, wasUnreadOnMount }: NotificationRowProps) { const { did: ownerDid } = useAuth() + // Notifications are the personal account's. While delegated (acting as a + // group) the accept/reject control is hidden — responding to your personal + // endorsements while "being" the org is a confusing cross-identity action. + const { activeOrg } = useOrg() const { info } = useAuthorInfo(notification.latestAuthor) const hasHandle = Boolean(info?.handle) const displayName = info?.handle || truncateDid(notification.latestAuthor) @@ -118,7 +123,7 @@ export default function NotificationRow({ notification, wasUnreadOnMount }: Noti return (
{content} - {isBadgeAward ? ( + {isBadgeAward && !activeOrg ? ( {actingAs.orgName} will endorse {subjectLabel}. You (@ - {actingAs.operatorHandle}) are acting as an admin. + {actingAs.operatorHandle}) are acting as its {actingAs.operatorRole}.

) : null} diff --git a/src/components/profile/profile-endorsements.tsx b/src/components/profile/profile-endorsements.tsx index 8ac1dc34..ed406bad 100644 --- a/src/components/profile/profile-endorsements.tsx +++ b/src/components/profile/profile-endorsements.tsx @@ -97,7 +97,14 @@ export default function ProfileEndorsements({ did }: ProfileEndorsementsProps) { // admin acting as this group) get the read-only view. const { activeOrg } = useOrg() const actingAsThisGroup = !!activeOrg && activeOrg.groupDid === did - const canManage = viewerIsOwner || actingAsThisGroup + // Owner-side affordances (give / revoke-given / accept-reject-received). + // CRITICAL: while delegated (activeOrg set) you must NOT manage your + // PERSONAL endorsements — that would write to your personal repo while + // the chrome says you're the org, which is exactly the confusing + // cross-identity action we forbid. So personal management requires + // `!activeOrg`; group management requires acting AS the very group whose + // profile this is. The two are mutually exclusive. + const canManage = (viewerIsOwner && !activeOrg) || actingAsThisGroup const manageTargetDid = actingAsThisGroup ? did : undefined const given = useGivenEndorsements(did) @@ -204,7 +211,7 @@ export default function ProfileEndorsements({ did }: ProfileEndorsementsProps) { viewers don't see the list curation UI. Hides the create / rename / member-add affordances + the list previews themselves when viewing someone else's profile. */} - {viewerIsOwner ? ( + {viewerIsOwner && !activeOrg ? ( ) : null} diff --git a/src/components/profile/profile-groups.tsx b/src/components/profile/profile-groups.tsx index 16f4060a..e8658418 100644 --- a/src/components/profile/profile-groups.tsx +++ b/src/components/profile/profile-groups.tsx @@ -2,13 +2,16 @@ import { useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" -import { ArrowUpDown, Building2, Check, Plus, Search } from "lucide-react" +import { useRouter } from "next/navigation" +import { ArrowUpDown, Building2, Check, LogIn, Plus, Search } from "lucide-react" import Avatar from "@/components/ui/avatar" import Button from "@/components/ui/button" import EmptyState from "@/components/ui/empty-state" import LoadingSpinner from "@/components/ui/loading-spinner" import { useCgsMemberships, type UserGroup } from "@/hooks/use-cgs-memberships" import { useAuth } from "@/lib/auth/auth-context" +import { useOrg } from "@/lib/groups/org-context" +import type { OrgRole } from "@/lib/groups/types" import { formatRelativeTime } from "@/lib/atproto/activity" import { getInitials } from "@/lib/utils/initials" @@ -194,11 +197,33 @@ interface GroupRowProps { } function GroupRow({ group }: GroupRowProps) { + const router = useRouter() + const { groups: orgGroups, activeOrg, switchOrg } = useOrg() const name = group.displayName || group.handle const initials = getInitials(name, group.groupDid) const joinedLabel = group.joinedAt ? `Joined ${formatRelativeTime(group.joinedAt)}` : null + const isOperating = activeOrg?.groupDid === group.groupDid + + // "Operate as" = act FOR this group (delegation). Deliberately separate + // from the row link, which goes to the group's profile. Prefer the + // org-context's canonical Group so the active-org reconciliation keeps it; + // fall back to a Group built from the CGS membership if it isn't loaded yet. + const handleOperateAs = () => { + const org = + orgGroups.find((g) => g.groupDid === group.groupDid) ?? { + groupDid: group.groupDid, + handle: group.handle, + displayName: group.displayName, + role: (group.role as OrgRole) ?? "member", + accepted: true, + avatarUrl: group.avatarUrl, + } + switchOrg(org) + router.push("/home") + } + return (
  • @@ -231,6 +256,22 @@ function GroupRow({ group }: GroupRowProps) { {group.role ? ( {group.role} ) : null} + {isOperating ? ( + + ) : ( + + )}
  • diff --git a/src/components/profile/profile-sidebar.tsx b/src/components/profile/profile-sidebar.tsx index 409e64b0..3943fff5 100644 --- a/src/components/profile/profile-sidebar.tsx +++ b/src/components/profile/profile-sidebar.tsx @@ -947,6 +947,7 @@ function EndorseButton({ orgName: activeOrg.displayName || activeOrg.handle, orgHandle: activeOrg.handle, operatorHandle: operatorHandle ?? "you", + operatorRole: activeOrg.role, } : undefined