diff --git a/src/app/api/groups/[groupDid]/ownership-transfer/__tests__/route.test.ts b/src/app/api/groups/[groupDid]/ownership-transfer/__tests__/route.test.ts new file mode 100644 index 00000000..03a463a1 --- /dev/null +++ b/src/app/api/groups/[groupDid]/ownership-transfer/__tests__/route.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +/** + * BFF contract for the ownership-transfer route. + * + * What matters here is the targeting form CGS requires (#27): the `status` + * QUERY carries `repo` on the querystring, while `propose` / `accept` / + * `cancel` are JSON-body procedures that carry it in the body. Getting that + * backwards is rejected upstream with `jwt audience does not match service + * did`, which is a confusing failure to debug from the UI — so it's pinned. + * + * `isValidDid` is the real guard under test; only the auth / CSRF / rate-limit + * / CGS seams are mocked. + */ + +vi.mock("@/lib/groups/proxy-agent", () => ({ + getAuthenticatedAgent: vi.fn(), + callGroupServiceJson: vi.fn(), +})) +vi.mock("@/lib/auth/csrf", () => ({ checkCsrf: vi.fn(() => null) })) +vi.mock("@/lib/auth/rate-limit", () => ({ + enforceRateLimit: vi.fn(async () => null), + makeLimiter: () => ({}), +})) + +import { + getAuthenticatedAgent, + callGroupServiceJson, +} from "@/lib/groups/proxy-agent" +import { checkCsrf } from "@/lib/auth/csrf" +import { enforceRateLimit } from "@/lib/auth/rate-limit" + +const GROUP_DID = "did:plc:abcdefghijklmnopqrstuvwx" +const MEMBER_DID = "did:plc:bcdefghijklmnopqrstuvwxy" + +function makeRequest(method: string, body?: unknown): Request { + return new Request( + `https://example.test/api/groups/${GROUP_DID}/ownership-transfer`, + { + method, + headers: { + "Content-Type": "application/json", + origin: "https://example.test", + }, + // A GET Request may not carry a body, so drop it there. + ...(body !== undefined && method !== "GET" + ? { body: JSON.stringify(body) } + : {}), + }, + ) +} + +function makeContext(groupDid = GROUP_DID) { + return { params: Promise.resolve({ groupDid }) } +} + +async function call( + method: "GET" | "POST" | "PUT" | "DELETE", + body?: unknown, + groupDid = GROUP_DID, +): Promise { + const mod = await import("../route") + const handler = mod[method] + return handler( + makeRequest(method, body) as unknown as Parameters[0], + makeContext(groupDid) as unknown as Parameters[1], + ) +} + +beforeEach(() => { + vi.mocked(checkCsrf).mockReset().mockReturnValue(null) + vi.mocked(enforceRateLimit).mockReset().mockResolvedValue(null) + vi.mocked(getAuthenticatedAgent) + .mockReset() + .mockResolvedValue({ agent: {} as never, did: "did:plc:alice" }) + vi.mocked(callGroupServiceJson).mockReset().mockResolvedValue({ ok: true }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("ownership-transfer — CGS targeting", () => { + it("puts repo on the querystring for the status query", async () => { + const res = await call("GET") + expect(res.status).toBe(200) + expect(callGroupServiceJson).toHaveBeenCalledWith( + expect.anything(), + "app.certified.group.ownershipTransfer.status", + { query: { repo: GROUP_DID } }, + ) + }) + + it("puts repo in the body for propose, with the proposed DID", async () => { + const res = await call("POST", { newOwner: MEMBER_DID }) + expect(res.status).toBe(200) + expect(callGroupServiceJson).toHaveBeenCalledWith( + expect.anything(), + "app.certified.group.ownershipTransfer.propose", + { body: { repo: GROUP_DID, newOwner: MEMBER_DID } }, + ) + }) + + it("puts repo in the body for accept, and names no member", async () => { + // CGS derives the accepting member from the JWT — a client-supplied + // identity here would be meaningless at best. + const res = await call("PUT") + expect(res.status).toBe(200) + expect(callGroupServiceJson).toHaveBeenCalledWith( + expect.anything(), + "app.certified.group.ownershipTransfer.accept", + { body: { repo: GROUP_DID } }, + ) + }) + + it("puts repo in the body for cancel", async () => { + const res = await call("DELETE") + expect(res.status).toBe(200) + expect(callGroupServiceJson).toHaveBeenCalledWith( + expect.anything(), + "app.certified.group.ownershipTransfer.cancel", + { body: { repo: GROUP_DID } }, + ) + }) +}) + +describe("ownership-transfer — validation", () => { + it("rejects a missing or malformed newOwner before calling CGS", async () => { + for (const newOwner of [undefined, "", "alice.test", "did:", 42]) { + vi.mocked(callGroupServiceJson).mockClear() + const res = await call("POST", { newOwner }) + expect(res.status).toBe(400) + expect(callGroupServiceJson).not.toHaveBeenCalled() + } + }) + + it("rejects an invalid group DID on every method", async () => { + for (const method of ["GET", "POST", "PUT", "DELETE"] as const) { + vi.mocked(callGroupServiceJson).mockClear() + const res = await call(method, { newOwner: MEMBER_DID }, "not-a-did") + expect(res.status).toBe(400) + expect(callGroupServiceJson).not.toHaveBeenCalled() + } + }) +}) + +describe("ownership-transfer — gates", () => { + it("returns the CSRF response before any upstream call on mutations", async () => { + for (const method of ["POST", "PUT", "DELETE"] as const) { + vi.mocked(callGroupServiceJson).mockClear() + vi.mocked(checkCsrf).mockReturnValueOnce( + new Response(JSON.stringify({ error: "csrf" }), { + status: 403, + }) as never, + ) + const res = await call(method, { newOwner: MEMBER_DID }) + expect(res.status).toBe(403) + expect(callGroupServiceJson).not.toHaveBeenCalled() + } + }) + + it("401s when not authenticated", async () => { + vi.mocked(getAuthenticatedAgent).mockResolvedValue(null) + const res = await call("GET") + expect(res.status).toBe(401) + expect(callGroupServiceJson).not.toHaveBeenCalled() + }) + + it("rate-limits propose before calling CGS", async () => { + vi.mocked(enforceRateLimit).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "slow down" }), { + status: 429, + }) as never, + ) + const res = await call("POST", { newOwner: MEMBER_DID }) + expect(res.status).toBe(429) + expect(callGroupServiceJson).not.toHaveBeenCalled() + }) +}) + +describe("ownership-transfer — upstream errors", () => { + it("forwards the atproto error code so the UI can tell cases apart", async () => { + vi.mocked(callGroupServiceJson).mockRejectedValueOnce( + Object.assign(new Error("Not a member"), { + status: 400, + error: "NotAMember", + }), + ) + const res = await call("POST", { newOwner: MEMBER_DID }) + expect(res.status).toBe(400) + expect((await res.json()).code).toBe("NotAMember") + }) + + it("passes a non-party's NoPendingTransfer straight through", async () => { + // A member who isn't a party gets the same 404 as when nothing is + // pending. The BFF must not try to distinguish them — the service + // makes the two indistinguishable on purpose. + vi.mocked(callGroupServiceJson).mockRejectedValueOnce( + Object.assign(new Error("No pending transfer"), { + status: 404, + error: "NoPendingTransfer", + }), + ) + const res = await call("PUT") + expect(res.status).toBe(404) + expect((await res.json()).code).toBe("NoPendingTransfer") + }) +}) diff --git a/src/app/api/groups/[groupDid]/ownership-transfer/route.ts b/src/app/api/groups/[groupDid]/ownership-transfer/route.ts new file mode 100644 index 00000000..9d723ab4 --- /dev/null +++ b/src/app/api/groups/[groupDid]/ownership-transfer/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from "next/server" +import { + getAuthenticatedAgent, + callGroupServiceJson, +} from "@/lib/groups/proxy-agent" +import { checkCsrf } from "@/lib/auth/csrf" +import { isValidDid } from "@/lib/utils/did" +import { extractRouteError, parseJsonBody } from "@/lib/utils/api" +import { enforceRateLimit, makeLimiter } from "@/lib/auth/rate-limit" + +/** + * Ownership transfer (CGS >= 0.6.0) — the two-phase handshake behind + * `app.certified.group.ownershipTransfer.*`. The owner proposes an existing + * member; ownership moves only once that member accepts, which proves they + * still control their DID. Either party can cancel, and an un-accepted + * proposal lapses after 7 days. + * + * Disclosure rule (CGS contract): a member who is NOT a party to a pending + * transfer gets exactly what they'd get if none existed — `pending: false` + * from the status query, `NoPendingTransfer` from accept/cancel. We forward + * those responses untouched; never try to distinguish "nothing pending" from + * "not yours" here or in the UI, because the service deliberately makes them + * indistinguishable. + * + * Requires the group service to be on 0.6.0+; a 0.5.0 deployment answers these + * NSIDs with `501 MethodNotImplemented`, which surfaces as a plain upstream + * error. That's gated by release (staging only until production's CGS is + * upgraded), not by runtime detection. + */ + +// 5 proposals per 10 min per DID. Proposing is owner-only and rare; +// the limit just bounds a loop that renews the same proposal (a +// re-propose restarts the 7-day window server-side). +const PROPOSE_LIMITER = makeLimiter("groups-transfer", 5, 600) + +/** + * GET /api/groups/[groupDid]/ownership-transfer + * Report the pending transfer, if any. Callable by any member; details are + * disclosed by CGS only to the two parties. + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ groupDid: string }> } +) { + try { + const { groupDid } = await params + if (!isValidDid(groupDid)) { + return NextResponse.json({ error: "Invalid group DID" }, { status: 400 }) + } + const auth = await getAuthenticatedAgent() + if (!auth) + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + + // A query: `repo` goes on the querystring (CGS #27 targeting). + const data = await callGroupServiceJson( + auth.agent, + "app.certified.group.ownershipTransfer.status", + { query: { repo: groupDid } } + ) + + return NextResponse.json(data) + } catch (err: unknown) { + const { status, message } = extractRouteError(err) + return NextResponse.json({ error: message }, { status }) + } +} + +/** + * POST /api/groups/[groupDid]/ownership-transfer + * Propose a new owner (requires owner). The target must already be a member. + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ groupDid: string }> } +) { + const csrfError = checkCsrf(request) + if (csrfError) return csrfError + + try { + const { groupDid } = await params + if (!isValidDid(groupDid)) { + return NextResponse.json({ error: "Invalid group DID" }, { status: 400 }) + } + const auth = await getAuthenticatedAgent() + if (!auth) + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + + const rateDenied = await enforceRateLimit(PROPOSE_LIMITER, auth.did) + if (rateDenied) return rateDenied + + const parsed = await parseJsonBody(request, "[groups/ownership-transfer POST]") + if (!parsed.ok) return parsed.response + const { newOwner } = (parsed.body ?? {}) as { newOwner?: string } + + // CGS accepts a handle or a DID; the settings UI always sends the DID of + // a member it already listed, so require that stricter form here. + if (!newOwner || !isValidDid(newOwner)) { + return NextResponse.json( + { error: "newOwner must be a DID" }, + { status: 400 } + ) + } + + const data = await callGroupServiceJson( + auth.agent, + "app.certified.group.ownershipTransfer.propose", + { body: { repo: groupDid, newOwner } } + ) + + return NextResponse.json(data) + } catch (err: unknown) { + const { status, message, code } = extractRouteError(err) + return NextResponse.json({ error: message, code }, { status }) + } +} + +/** + * PUT /api/groups/[groupDid]/ownership-transfer + * Accept a pending transfer. Callable only by the proposed member; CGS demotes + * the previous owner to admin and promotes the caller atomically. + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ groupDid: string }> } +) { + const csrfError = checkCsrf(request) + if (csrfError) return csrfError + + try { + const { groupDid } = await params + if (!isValidDid(groupDid)) { + return NextResponse.json({ error: "Invalid group DID" }, { status: 400 }) + } + const auth = await getAuthenticatedAgent() + if (!auth) + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + + const data = await callGroupServiceJson( + auth.agent, + "app.certified.group.ownershipTransfer.accept", + { body: { repo: groupDid } } + ) + + return NextResponse.json(data) + } catch (err: unknown) { + const { status, message, code } = extractRouteError(err) + return NextResponse.json({ error: message, code }, { status }) + } +} + +/** + * DELETE /api/groups/[groupDid]/ownership-transfer + * Cancel a pending transfer — the owner revoking, or the proposed member + * declining. Either party may call it. + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ groupDid: string }> } +) { + const csrfError = checkCsrf(request) + if (csrfError) return csrfError + + try { + const { groupDid } = await params + if (!isValidDid(groupDid)) { + return NextResponse.json({ error: "Invalid group DID" }, { status: 400 }) + } + const auth = await getAuthenticatedAgent() + if (!auth) + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + + const data = await callGroupServiceJson( + auth.agent, + "app.certified.group.ownershipTransfer.cancel", + { body: { repo: groupDid } } + ) + + return NextResponse.json(data) + } catch (err: unknown) { + const { status, message, code } = extractRouteError(err) + return NextResponse.json({ error: message, code }, { status }) + } +} diff --git a/src/components/groups/__tests__/org-settings-transfer-gate.test.ts b/src/components/groups/__tests__/org-settings-transfer-gate.test.ts new file mode 100644 index 00000000..0eb71b02 --- /dev/null +++ b/src/components/groups/__tests__/org-settings-transfer-gate.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest" +import { + transferViewerRole, + eligibleTransferTargets, +} from "../org-settings" +import type { OrgRole } from "@/lib/groups/types" + +/** + * Who sees the Transfer ownership page, and who may be proposed. + * + * The disclosure rule is the point: CGS reports `pending: false` to a member + * who is not a party to a transfer, exactly as it does when none exists. The + * UI must therefore derive "show this page" from the response alone and never + * try to tell those two states apart. + */ + +const OWNER = "did:plc:owner" +const ALICE = "did:plc:alice" +const BOB = "did:plc:bob" + +const pendingFor = (proposedOwner: string) => + ({ + groupDid: "did:plc:group", + pending: true as const, + proposedOwner, + proposedBy: OWNER, + createdAt: "2026-09-01T00:00:00.000Z", + expiresAt: "2026-09-08T00:00:00.000Z", + }) + +const nothingPending = { + groupDid: "did:plc:group", + pending: false as const, +} + +describe("transferViewerRole", () => { + it("treats the owner as a party whether or not a transfer is pending", () => { + expect( + transferViewerRole({ transfer: null, callerDid: OWNER, isOwner: true }), + ).toBe("owner") + expect( + transferViewerRole({ + transfer: pendingFor(ALICE), + callerDid: OWNER, + isOwner: true, + }), + ).toBe("owner") + }) + + it("makes the proposed member the recipient", () => { + expect( + transferViewerRole({ + transfer: pendingFor(ALICE), + callerDid: ALICE, + isOwner: false, + }), + ).toBe("recipient") + }) + + it("gives a member proposed to someone else nothing", () => { + expect( + transferViewerRole({ + transfer: pendingFor(ALICE), + callerDid: BOB, + isOwner: false, + }), + ).toBe("none") + }) + + it("gives a non-party the same 'none' as when nothing is pending", () => { + // What a bystander actually receives from CGS is `pending: false`, so + // this is the case that really matters. + expect( + transferViewerRole({ + transfer: nothingPending, + callerDid: BOB, + isOwner: false, + }), + ).toBe("none") + }) + + it("returns 'none' when the caller DID is unknown", () => { + for (const callerDid of [null, undefined, ""]) { + expect( + transferViewerRole({ + transfer: pendingFor(ALICE), + callerDid, + isOwner: false, + }), + ).toBe("none") + } + }) + + it("returns 'none' before the status call resolves", () => { + expect( + transferViewerRole({ transfer: null, callerDid: ALICE, isOwner: false }), + ).toBe("none") + }) +}) + +describe("eligibleTransferTargets", () => { + const members: { did: string; role: OrgRole }[] = [ + { did: OWNER, role: "owner" }, + { did: ALICE, role: "admin" }, + { did: BOB, role: "member" }, + ] + + it("offers admins and members, never the current owner", () => { + // CGS rejects proposing the incumbent with `AlreadyOwner`. + expect(eligibleTransferTargets(members, OWNER).map((m) => m.did)).toEqual([ + ALICE, + BOB, + ]) + }) + + it("never offers the caller themselves", () => { + expect(eligibleTransferTargets(members, ALICE).map((m) => m.did)).toEqual([ + BOB, + ]) + }) + + it("is empty for a group whose only member is the owner", () => { + expect(eligibleTransferTargets([{ did: OWNER, role: "owner" }], OWNER)).toEqual( + [], + ) + }) +}) diff --git a/src/components/groups/org-settings.tsx b/src/components/groups/org-settings.tsx index 1cebc8fa..6f2e0fe3 100644 --- a/src/components/groups/org-settings.tsx +++ b/src/components/groups/org-settings.tsx @@ -8,6 +8,7 @@ import { ChevronLeft, ChevronRight, CircleUser, + Crown, ScrollText, Share2, ShieldAlert, @@ -40,9 +41,20 @@ import { setOrgMemberRole, queryOrgAuditLog, destroyGroup, + getOwnershipTransfer, + proposeOwnershipTransfer, + acceptOwnershipTransfer, + cancelOwnershipTransfer, } from "@/lib/groups/api" -import type { Group, OrgMember, AuditEntry, OrgRole } from "@/lib/groups/types" +import type { + Group, + OrgMember, + AuditEntry, + OrgRole, + OwnershipTransfer, +} from "@/lib/groups/types" import { authFetch } from "@/lib/auth/fetch" +import { formatShortDate } from "@/lib/utils/format-date" import Button from "@/components/ui/button" import Badge from "@/components/ui/badge" import Select from "@/components/ui/select" @@ -59,6 +71,7 @@ type CategoryKey = | "handle" | "social-graph" | "members" + | "ownership" | "activity" | "danger" @@ -112,6 +125,14 @@ const GROUPS: CategoryGroup[] = [ description: "Manage who can access and act on behalf of this group.", Icon: Users, }, + { + key: "ownership", + label: "Transfer ownership", + navLabel: "Ownership", + description: + "Hand this group to another member. They become the owner only once they accept, and you become an admin at that point. An un-accepted transfer expires after 7 days.", + Icon: Crown, + }, { key: "activity", label: "Activity Log", @@ -180,6 +201,38 @@ export function canRemoveMember(params: { ) } +/** + * Which side of an ownership transfer the viewer is on — this decides whether + * the Transfer ownership page appears at all, and what it offers. + * + * Only the two parties can act: the owner proposes and can revoke, and the + * member named in a pending proposal can accept or decline. Everyone else gets + * "none" and never sees the page. That leaks nothing: CGS reports + * `pending: false` to a member who isn't a party, exactly as it does when no + * transfer exists, so a bystander can't tell the two apart either way. + */ +export function transferViewerRole(params: { + transfer: OwnershipTransfer | null + callerDid: string | null | undefined + isOwner: boolean +}): "owner" | "recipient" | "none" { + const { transfer, callerDid, isOwner } = params + if (isOwner) return "owner" + if (transfer?.pending && callerDid && transfer.proposedOwner === callerDid) { + return "recipient" + } + return "none" +} + +// Who the owner may propose as the next owner: any member except the current +// owner (CGS rejects `AlreadyOwner`) and except themselves. CGS also requires +// the target to already be a member, which everyone in this list is. +export function eligibleTransferTargets< + T extends { did: string; role: OrgRole }, +>(members: T[], callerDid: string | null | undefined): T[] { + return members.filter((m) => m.role !== "owner" && m.did !== callerDid) +} + // groups-6: when the add-members loop fails part-way through, the members // before `failedIndex` were already accepted by the service. Re-staging the // whole list would double-add them, so keep only the failing member onward @@ -229,9 +282,6 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { : g, ) - // Owners get the Danger zone (remove group) in the rail + panel. - const visibleGroups = isOwner ? [...baseGroups, DANGER_GROUP] : baseGroups - // Remove-group (destroy) state. const [confirmDestroy, setConfirmDestroy] = useState(false) const [destroying, setDestroying] = useState(false) @@ -293,6 +343,36 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { const [isAdding, setIsAdding] = useState(false) const [addError, setAddError] = useState(null) + // Ownership transfer (CGS >= 0.6.0) + const [transfer, setTransfer] = useState(null) + const [transferError, setTransferError] = useState(null) + const [transferBusy, setTransferBusy] = useState(false) + const [transferTarget, setTransferTarget] = useState("") + const [confirmPropose, setConfirmPropose] = useState(false) + const [confirmAccept, setConfirmAccept] = useState(false) + + // Transfer ownership is only actionable by the two parties — the owner, and + // a member with a pending proposal naming them. Everyone else never sees the + // page (see `transferViewerRole`). + const viewerTransferRole = transferViewerRole({ + transfer, + callerDid: did, + isOwner, + }) + const transferGroups: CategoryGroup[] = + viewerTransferRole === "none" + ? baseGroups.map((g) => + g.label === "Group Management" + ? { ...g, items: g.items.filter((c) => c.key !== "ownership") } + : g, + ) + : baseGroups + + // Owners get the Danger zone (remove group) in the rail + panel. + const visibleGroups = isOwner + ? [...transferGroups, DANGER_GROUP] + : transferGroups + // Audit log const [auditEntries, setAuditEntries] = useState([]) const [auditLoading, setAuditLoading] = useState(true) @@ -366,12 +446,33 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { [groupDid, isAdmin] ) + const fetchTransfer = useCallback( + async (signal?: AbortSignal) => { + try { + const next = await getOwnershipTransfer(groupDid, signal) + if (!signal?.aborted) { + setTransfer(next) + setTransferError(null) + } + } catch (err) { + if (signal?.aborted) return + // Leave `transfer` as-is: a non-party's page stays hidden either way, + // and the owner (whose page is always in the rail) sees this inline. + setTransferError( + err instanceof Error ? err.message : "Couldn't read transfer status" + ) + } + }, + [groupDid] + ) + useEffect(() => { const controller = new AbortController() fetchMembers(controller.signal) fetchAudit(controller.signal) + fetchTransfer(controller.signal) return () => controller.abort() - }, [fetchMembers, fetchAudit]) + }, [fetchMembers, fetchAudit, fetchTransfer]) const handleAddMembers = async () => { if (pendingMembers.length === 0) return @@ -393,7 +494,7 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { } setPendingMembers([]) setNewMemberRole("member") - await Promise.all([fetchMembers(), fetchAudit()]) + await Promise.all([fetchMembers(), fetchAudit(), fetchTransfer()]) } finally { setIsAdding(false) } @@ -407,7 +508,9 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { try { await removeOrgMember(groupDid, memberDid) setConfirmRemove(null) - await Promise.all([fetchMembers(), fetchAudit()]) + // Removing a party clears any pending proposal service-side, so the + // transfer state has to be re-read rather than kept. + await Promise.all([fetchMembers(), fetchAudit(), fetchTransfer()]) } catch (err) { setMemberError( err instanceof Error ? err.message : "Failed to remove member" @@ -419,7 +522,8 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { const handleRoleChange = async (memberDid: string, role: OrgRole) => { try { await setOrgMemberRole(groupDid, memberDid, role) - await Promise.all([fetchMembers(), fetchAudit()]) + // A role change also clears a pending proposal service-side. + await Promise.all([fetchMembers(), fetchAudit(), fetchTransfer()]) } catch (err) { setMemberError( err instanceof Error ? err.message : "Failed to change role" @@ -427,6 +531,61 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { } } + const handlePropose = async () => { + if (!transferTarget) return + setTransferBusy(true) + setTransferError(null) + try { + await proposeOwnershipTransfer(groupDid, transferTarget) + setConfirmPropose(false) + setTransferTarget("") + await Promise.all([fetchTransfer(), fetchAudit()]) + } catch (err) { + setTransferError( + err instanceof Error ? err.message : "Failed to propose transfer" + ) + setConfirmPropose(false) + } finally { + setTransferBusy(false) + } + } + + const handleAcceptTransfer = async () => { + setTransferBusy(true) + setTransferError(null) + try { + await acceptOwnershipTransfer(groupDid) + setConfirmAccept(false) + // Roles just swapped — the caller is the owner and the proposer an + // admin. Refresh the org context too, since `org.role` drives which + // pages and controls this whole screen renders. + await Promise.all([fetchTransfer(), fetchMembers(), fetchAudit()]) + await refetchOrgs() + } catch (err) { + setTransferError( + err instanceof Error ? err.message : "Failed to accept transfer" + ) + setConfirmAccept(false) + } finally { + setTransferBusy(false) + } + } + + const handleCancelTransfer = async () => { + setTransferBusy(true) + setTransferError(null) + try { + await cancelOwnershipTransfer(groupDid) + await Promise.all([fetchTransfer(), fetchAudit()]) + } catch (err) { + setTransferError( + err instanceof Error ? err.message : "Failed to cancel transfer" + ) + } finally { + setTransferBusy(false) + } + } + // ----- Router-style nav (mirrors for personal accounts) --- // `null` = no page actively selected → first page on desktop, master list // on mobile. Driven by the `#` hash so deep links + back/forward work. @@ -753,6 +912,138 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { ) + // Ownership transfer (CGS >= 0.6.0). Only the two parties get here — + // `viewerTransferRole` keeps the page out of everyone else's rail. + case "ownership": { + const pending = transfer?.pending ? transfer : null + const targets = eligibleTransferTargets(members, did) + const identityFor = (memberDid: string) => { + const m = members.find((x) => x.did === memberDid) + return ( + + ) + } + + return ( +
+ {transferError && } + + {pending ? ( +
+
+

Transfer in progress

+

+ {viewerTransferRole === "recipient" + ? "You've been proposed as this group's owner. Accepting makes you the owner, and the current owner becomes an admin." + : "Nothing changes until the proposed owner accepts. You can revoke it before then."} +

+
+ +
+
+ {identityFor(pending.proposedOwner)} +
+
+ proposed owner +
+
+ +

+ Proposed {formatShortDate(pending.createdAt)} — expires{" "} + {formatShortDate(pending.expiresAt)} if it isn't + accepted. +

+ +
+ {viewerTransferRole === "recipient" ? ( + <> + + + + ) : ( + + )} +
+
+ ) : ( +
+
+

Choose the new owner

+
+ + {membersLoading ? ( +
+ +
+ ) : targets.length === 0 ? ( +

+ This group has no other members yet. Add someone under + Members & Roles first. +

+ ) : ( +
+ + +
+ )} +
+ )} + +

+ This moves control of the group within Certified. It does not + hand over the group account's own sign-in credentials. +

+
+ ) + } + case "activity": return !isAdmin ? (

Only admins and owners can view the activity log.

@@ -923,6 +1214,31 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { /> ) : null} + {confirmPropose ? ( + m.did === transferTarget)?.handle ?? + transferTarget + } as the owner of @${org.handle}? They become the owner once they accept — and you become an admin. You can cancel until then.`} + confirmLabel="Propose transfer" + isConfirming={transferBusy} + onCancel={() => setConfirmPropose(false)} + onConfirm={handlePropose} + /> + ) : null} + + {confirmAccept ? ( + setConfirmAccept(false)} + onConfirm={handleAcceptTransfer} + /> + ) : null} + {confirmDestroy ? ( { + const res = await authFetch( + `/api/groups/${encodeURIComponent(groupDid)}/ownership-transfer`, + { signal } + ) + if (!res.ok) { + throw new Error(await extractError(res, "Failed to read ownership transfer")) + } + return res.json() +} + +/** + * Propose transferring ownership to another member (owner only). + * Ownership does not move until they accept. Re-proposing the same member + * renews the 7-day expiry. + */ +export async function proposeOwnershipTransfer( + groupDid: string, + newOwnerDid: string +): Promise { + const res = await authFetch( + `/api/groups/${encodeURIComponent(groupDid)}/ownership-transfer`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newOwner: newOwnerDid }), + } + ) + if (!res.ok) { + throw new Error(await extractError(res, "Failed to propose transfer")) + } +} + +/** + * Accept a pending transfer — callable only by the proposed member. The + * previous owner becomes an admin and the caller becomes the owner. + */ +export async function acceptOwnershipTransfer(groupDid: string): Promise { + const res = await authFetch( + `/api/groups/${encodeURIComponent(groupDid)}/ownership-transfer`, + { method: "PUT", headers: { "Content-Type": "application/json" } } + ) + if (!res.ok) { + throw new Error(await extractError(res, "Failed to accept transfer")) + } +} + +/** + * Cancel a pending transfer — the owner revoking it, or the proposed member + * declining it. + */ +export async function cancelOwnershipTransfer(groupDid: string): Promise { + const res = await authFetch( + `/api/groups/${encodeURIComponent(groupDid)}/ownership-transfer`, + { method: "DELETE", headers: { "Content-Type": "application/json" } } + ) + if (!res.ok) { + throw new Error(await extractError(res, "Failed to cancel transfer")) + } +} + /** * Query the group audit log. * Paginates if the endpoint returns a cursor. diff --git a/src/lib/groups/types.ts b/src/lib/groups/types.ts index a2181b5d..4c087d88 100644 --- a/src/lib/groups/types.ts +++ b/src/lib/groups/types.ts @@ -25,6 +25,28 @@ export interface OrgMember { addedAt: string } +/** + * State of a group's pending ownership transfer + * (`app.certified.group.ownershipTransfer.status`, CGS >= 0.6.0). + * + * A member who is not a party to a pending transfer receives the same + * `pending: false` as when none exists — the service makes the two cases + * indistinguishable on purpose, so don't try to tell them apart. + */ +export type OwnershipTransfer = + | { groupDid: string; pending: false } + | { + groupDid: string + pending: true + /** DID of the member proposed as the new owner. */ + proposedOwner: string + /** DID of the current owner, who proposed the transfer. */ + proposedBy: string + createdAt: string + /** 7 days after `createdAt`; a re-propose restarts the window. */ + expiresAt: string + } + export type OrgProfile = CertifiedProfile export interface GroupMetadata {