Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/__tests__/endorsements-received-rejected.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
Expand Down
11 changes: 11 additions & 0 deletions src/app/endorsements/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -208,13 +209,23 @@ const DEFAULT_TAB: TabKey = "given"
export default function EndorsementsPage() {
usePageTitle("Endorsements")
const { did } = useAuth()
const { activeOrg } = useOrg()

// Tab state lives in `?tab=<key>` on the URL so refresh / shared
// link lands on the same view. The default tab stays bare to keep
// the URL clean.
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<TabKey>(() => {
const v = searchParams?.get("tab")
return v && TABS.some((t) => t.key === v) ? (v as TabKey) : DEFAULT_TAB
Expand Down
7 changes: 6 additions & 1 deletion src/components/notifications/notification-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -118,7 +123,7 @@ export default function NotificationRow({ notification, wasUnreadOnMount }: Noti
return (
<div className={className}>
{content}
{isBadgeAward ? (
{isBadgeAward && !activeOrg ? (
<ResponseButtons
awardUri={notification.latestRecordUri}
awardCid={notification.latestRecordCid}
Expand Down
5 changes: 4 additions & 1 deletion src/components/profile/endorse-reason-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export interface EndorseReasonActingAs {
orgHandle: string
/** Handle of the signed-in operator acting on the group's behalf. */
operatorHandle: string
/** The operator's actual role in the group (owner / admin / member) —
* never hard-coded, so the copy reflects the real relationship. */
operatorRole: string
}

interface EndorseReasonModalProps {
Expand Down Expand Up @@ -130,7 +133,7 @@ export default function EndorseReasonModal({
{actingAs ? (
<p className="endorse-reason-modal__acting-as" role="note">
<b>{actingAs.orgName}</b> will endorse {subjectLabel}. You (@
{actingAs.operatorHandle}) are acting as an admin.
{actingAs.operatorHandle}) are acting as its {actingAs.operatorRole}.
</p>
) : null}

Expand Down
11 changes: 9 additions & 2 deletions src/components/profile/profile-endorsements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ? (
<EndorsementLists did={did} viewerIsOwner={viewerIsOwner} />
) : null}

Expand Down
43 changes: 42 additions & 1 deletion src/components/profile/profile-groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 (
<li className="profile-groups__item">
<div className="profile-groups__row">
Expand Down Expand Up @@ -231,6 +256,22 @@ function GroupRow({ group }: GroupRowProps) {
{group.role ? (
<span className="profile-groups__role">{group.role}</span>
) : null}
{isOperating ? (
<Button variant="secondary" size="sm" disabled>
<Check size={14} strokeWidth={2} aria-hidden />
Operating
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={handleOperateAs}
title={`Operate as ${name}`}
>
<LogIn size={14} strokeWidth={1.75} aria-hidden />
Operate as
</Button>
)}
</div>
</div>
</li>
Expand Down
1 change: 1 addition & 0 deletions src/components/profile/profile-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ function EndorseButton({
orgName: activeOrg.displayName || activeOrg.handle,
orgHandle: activeOrg.handle,
operatorHandle: operatorHandle ?? "you",
operatorRole: activeOrg.role,
}
: undefined

Expand Down