From fd1eb36b89b963eb0552b3e820a2ee80ad1089a4 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:31:33 +0200 Subject: [PATCH] feat(dashboard): transfer workspace ownership (#2927) * feat(dashboard): transfer workspace ownership An owner can make another member the owner from Settings > General > Members, behind a type-to-confirm dialog. The transfer is immediate: the target becomes owner, the caller becomes member. - services: `transferOwnership` verb; promotes before it demotes and the demote is conditional on `role = 'owner'`, so concurrent transfers resolve to one winner. Two `member.update` audit rows, paired via metadata. `transferOwnershipInWorkspace` is the internal body for support transfers with a system actor. - api: `member.transferOwnership`. After the response it emails both people and moves the Stripe customer email to the new owner when it still equals the former owner's. `afterResponse` moves to lib. - emails: `ownership-transferred` template. - dashboard: `FormAlertDialog` becomes composable parts (the delete preset keeps its API); members show role badges, and only owners see the row actions. - docs: workspace members reference. * fix(members): address ownership transfer review Require transfer metadata on member.update audit rows, keep the confirm action neutral outside the delete preset, label the confirm input, show the billing note whenever a Stripe customer is linked, document the admin role, and avoid a nested transaction in the rollback test. * fix(members): scope billing email copy to openstatus emails --- .../settings/members/data-table.tsx | 103 ++++- .../members/transfer-ownership-dialog.tsx | 96 ++++ .../components/forms/form-alert-dialog.tsx | 263 ++++++++--- apps/web/src/content/docs.config.ts | 4 + .../docs/reference/workspace-members.mdx | 41 ++ packages/analytics/src/events.ts | 4 + packages/api/src/lib/after-response.ts | 23 + packages/api/src/router/incident.ts | 12 +- packages/api/src/router/member.ts | 48 ++ packages/api/src/router/stripe/shared.test.ts | 65 +++ packages/api/src/router/stripe/shared.ts | 34 ++ .../db/src/schema/audit_logs/validation.ts | 9 + .../emails/emails/ownership-transferred.tsx | 73 ++++ packages/emails/src/billing.test.tsx | 59 +++ packages/emails/src/billing.tsx | 34 ++ packages/emails/src/index.ts | 1 + .../__tests__/transfer-ownership.test.ts | 411 ++++++++++++++++++ packages/services/src/member/index.ts | 2 + packages/services/src/member/schemas.ts | 3 + .../services/src/member/transfer-ownership.ts | 200 +++++++++ 20 files changed, 1399 insertions(+), 86 deletions(-) create mode 100644 apps/dashboard/src/components/data-table/settings/members/transfer-ownership-dialog.tsx create mode 100644 apps/web/src/content/pages/docs/reference/workspace-members.mdx create mode 100644 packages/api/src/lib/after-response.ts create mode 100644 packages/api/src/router/stripe/shared.test.ts create mode 100644 packages/emails/emails/ownership-transferred.tsx create mode 100644 packages/services/src/member/__tests__/transfer-ownership.test.ts create mode 100644 packages/services/src/member/transfer-ownership.ts diff --git a/apps/dashboard/src/components/data-table/settings/members/data-table.tsx b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx index 6022ba913..6fa1ebc45 100644 --- a/apps/dashboard/src/components/data-table/settings/members/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx @@ -1,3 +1,4 @@ +import { ShieldUser } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Table, @@ -8,12 +9,14 @@ import { TableRow, } from "@openstatus/ui/components/ui/table"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { formatDate } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { TableCellEmpty } from "../../table-cell-empty"; +import { TransferOwnershipDialog } from "./transfer-ownership-dialog"; export function DataTable() { const trpc = useTRPC(); @@ -28,6 +31,10 @@ export function DataTable() { if (!members) return null; + const currentUserId = user?.id; + const isOwner = + members.find((m) => m.user.id === currentUserId)?.role === "owner"; + return ( @@ -43,11 +50,9 @@ export function DataTable() { {members.map((item) => { - const currentUserId = user?.id; const isMe = Boolean(currentUserId && item.user.id === currentUserId); - const canDelete = Boolean( - currentUserId && item.user.id !== currentUserId, - ); + // The server rejects non-owners; don't offer what will fail. + const canDelete = isOwner && !isMe; return ( @@ -58,26 +63,28 @@ export function DataTable() { {item.user.email} - {item.role} + + + {item.role} + + {formatDate(item.user.createdAt ?? item.createdAt)} {canDelete ? ( -
- - await deleteMemberMutation.mutateAsync({ - id: item.user.id, - }), - }} - /> -
+ + await deleteMemberMutation.mutateAsync({ + id: item.user.id, + }) + } + /> ) : null}
@@ -87,3 +94,61 @@ export function DataTable() {
); } + +function MemberRowActions({ + member, + ssoEnabled, + onDelete, +}: { + member: { + role: string; + user: { + id: number; + name: string | null; + email: string | null; + deletedAt: Date | null; + }; + }; + ssoEnabled: boolean; + onDelete: () => Promise; +}) { + const [transferOpen, setTransferOpen] = useState(false); + const { user } = member; + const canTransfer = + member.role !== "owner" && !user.deletedAt && !!user.email; + + return ( +
+ setTransferOpen(true), + }, + ] + : undefined + } + deleteAction={{ + confirmationValue: user.email ?? "user", + description: ssoEnabled + ? "This workspace uses SSO. They can sign in again and rejoin automatically unless you also remove them from your identity provider." + : undefined, + submitAction: onDelete, + }} + /> + {/* Outside `QuickActions`: its dropdown item unmounts with the menu. */} + {canTransfer && user.email ? ( + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/settings/members/transfer-ownership-dialog.tsx b/apps/dashboard/src/components/data-table/settings/members/transfer-ownership-dialog.tsx new file mode 100644 index 000000000..ed0312865 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/members/transfer-ownership-dialog.tsx @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + FormAlertDialogAction, + FormAlertDialogCancel, + FormAlertDialogConfirmInput, + FormAlertDialogContent, + FormAlertDialogDescription, + FormAlertDialogFooter, + FormAlertDialogHeader, + FormAlertDialogRoot, + FormAlertDialogTitle, +} from "@/components/forms/form-alert-dialog"; +import { useTRPC } from "@/lib/trpc/client"; + +export function TransferOwnershipDialog({ + member, + open, + onOpenChange, +}: { + member: { id: number; name: string | null; email: string }; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const transferOwnershipMutation = useMutation( + trpc.member.transferOwnership.mutationOptions({ + onSuccess: () => { + // The SSO and account pages derive `isOwner` from `member.list`. + queryClient.invalidateQueries({ + queryKey: trpc.member.list.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.workspace.get.queryKey(), + }); + }, + }), + ); + + // The router syncs the Stripe customer email whenever one is linked, paid + // plan or not. + const hasStripeCustomer = !!workspace?.stripeId; + const workspaceName = workspace?.name || workspace?.slug || "this workspace"; + + return ( + { + await transferOwnershipMutation.mutateAsync({ id: member.id }); + }} + > + + + + Transfer ownership to {member.name || member.email}? + + +
    +
  • + {member.email} becomes the owner of {workspaceName}. +
  • +
  • + You become a member. You lose access to SSO settings and member + management, and you can't undo this yourself. +
  • + {hasStripeCustomer ? ( +
  • + Billing emails from openstatus go to the new owner. If the + billing email on your subscription is your address, it moves + to them too. A different address, such as a finance inbox, + stays as it is. +
  • + ) : null} +
+
+
+ + + + + Transfer + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/forms/form-alert-dialog.tsx b/apps/dashboard/src/components/forms/form-alert-dialog.tsx index 8d92a5b0e..324cbf9b5 100644 --- a/apps/dashboard/src/components/forms/form-alert-dialog.tsx +++ b/apps/dashboard/src/components/forms/form-alert-dialog.tsx @@ -15,104 +15,253 @@ import { import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; +import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { useState, useTransition } from "react"; +import { createContext, useContext, useState, useTransition } from "react"; import { toast } from "sonner"; -interface FormAlertDialogProps { +/** + * Type-to-confirm alert dialog, built from parts: + * + * + * … + * + * + * … + * … + * + * + * + * + * Delete + * + * + * + * + * `FormAlertDialog` is the delete preset of the same parts. + */ + +const FORM_ID = "form-alert-dialog"; + +const DESTRUCTIVE_ACTION = + "bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white shadow-xs"; + +type FormAlertDialogContextValue = { confirmationValue: string; - submitAction: () => Promise; - children?: React.ReactNode; + value: string; + setValue: (value: string) => void; + isPending: boolean; + submit: () => void; +}; + +const FormAlertDialogContext = + createContext(null); + +function useFormAlertDialog() { + const context = useContext(FormAlertDialogContext); + if (!context) { + throw new Error( + "FormAlertDialog parts must be used within ", + ); + } + return context; } -export function FormAlertDialog({ +type ToastCopy = { loading: string; success: string; error: string }; + +const DELETE_TOAST: ToastCopy = { + loading: "Deleting...", + success: "Deleted", + error: "Failed to delete", +}; + +export function FormAlertDialogRoot({ confirmationValue, submitAction, + toast: toastCopy = DELETE_TOAST, + open: openProp, + defaultOpen = false, + onOpenChange, children, -}: FormAlertDialogProps) { +}: Omit, "open"> & { + confirmationValue: string; + submitAction: () => Promise; + toast?: ToastCopy; + /** Control it when the trigger unmounts, e.g. a dropdown menu item. */ + open?: boolean; +}) { const [value, setValue] = useState(""); const [isPending, startTransition] = useTransition(); - const { copy, isCopied } = useCopyToClipboard(); - const [open, setOpen] = useState(false); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const open = openProp ?? uncontrolledOpen; const handleOpenChange = (next: boolean) => { - setOpen(next); + if (openProp === undefined) setUncontrolledOpen(next); + onOpenChange?.(next); if (!next) setValue(""); }; - const handleDelete = () => { + const submit = () => { startTransition(async () => { try { const promise = submitAction(); toast.promise(promise, { - loading: "Deleting...", - success: "Deleted", + loading: toastCopy.loading, + success: toastCopy.success, error: (error) => { if (isTRPCClientError(error)) { return error.message; } - return "Failed to delete"; + return toastCopy.error; }, }); await promise; handleOpenChange(false); } catch (error) { - console.error("Failed to delete:", error); + console.error("Failed to submit:", error); } }); }; return ( - - + + + {children} + + + ); +} + +export const FormAlertDialogTrigger = AlertDialogTrigger; +export const FormAlertDialogContent = AlertDialogContent; +export const FormAlertDialogHeader = AlertDialogHeader; +export const FormAlertDialogTitle = AlertDialogTitle; +export const FormAlertDialogFooter = AlertDialogFooter; + +/** Renders a `div`, so it can hold a list, which a `p` cannot. */ +export function FormAlertDialogDescription({ + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export function FormAlertDialogConfirmInput({ + className, + ...props +}: React.ComponentProps<"form">) { + const { confirmationValue, value, setValue } = useFormAlertDialog(); + const { copy, isCopied } = useCopyToClipboard(); + + return ( +
+

+ Type{" "} + {" "} + to confirm +

+ setValue(e.target.value)} + /> +
+ ); +} + +export function FormAlertDialogCancel({ + children = "Cancel", + ...props +}: React.ComponentProps) { + return {children}; +} + +/** Disabled until the confirmation value is typed; submits the root action. */ +export function FormAlertDialogAction({ + children, + pendingLabel, + className, + ...props +}: React.ComponentProps & { + pendingLabel: React.ReactNode; +}) { + const { confirmationValue, value, isPending, submit } = useFormAlertDialog(); + + return ( + { + props.onClick?.(e); + e.preventDefault(); + submit(); + }} + > + {isPending ? pendingLabel : children} + + ); +} + +/** Delete preset: a destructive button that asks to type `confirmationValue`. */ +export function FormAlertDialog({ + confirmationValue, + submitAction, + children, +}: { + confirmationValue: string; + submitAction: () => Promise; + children?: React.ReactNode; +}) { + return ( + + {children ?? ( )} -
- - - + + + + Are you sure about delete `{confirmationValue}`? - - + + This action cannot be undone. This will permanently delete the item. - - -
-

- Type{" "} - {" "} - to confirm -

- setValue(e.target.value)} /> -
- - Cancel - { - e.preventDefault(); - handleDelete(); - }} + + + + + + - {isPending ? "Deleting..." : "Delete"} - - -
-
+ Delete + + + +
); } diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index af702a6b4..dc6b8074b 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -280,6 +280,10 @@ export const docsNav: DocsNavSection[] = [ { slug: "reference/maintenance", label: "Maintenance Reference" }, { slug: "reference/subscriber", label: "Subscriber Reference" }, { slug: "reference/terraform", label: "Terraform Provider Reference" }, + { + slug: "reference/workspace-members", + label: "Workspace Members Reference", + }, ], }, { diff --git a/apps/web/src/content/pages/docs/reference/workspace-members.mdx b/apps/web/src/content/pages/docs/reference/workspace-members.mdx new file mode 100644 index 000000000..67db98c79 --- /dev/null +++ b/apps/web/src/content/pages/docs/reference/workspace-members.mdx @@ -0,0 +1,41 @@ +--- +category: Reference +title: Workspace Members Reference +description: Roles in a workspace, inviting members, and transferring ownership. +--- + +A workspace has one owner and any number of members. You manage both under **Settings > General > Members**. + +Inviting members requires a paid plan. The free plan holds a single member, and a downgrade to free removes every member except the owner. + +## Roles + +| Role | Can do | +|---|---| +| Owner | Everything an admin can, plus: remove members, configure SAML SSO, and start a trial. Receives billing emails. | +| Admin | Everything a member can, plus: delete incidents, close incidents and approve postmortems (the incident commander can too), and revoke other members' OAuth grants. | +| Member | Manage monitors, status pages, notifications, incidents, and billing. | + +## Invite a member + +1. Go to **Settings > General > Members** and invite the person by email. +2. They accept the invitation from the email and join as a member. + +## Transfer ownership + +The owner can make another member the owner. + +1. Make sure the new owner has **accepted** their invitation. You cannot transfer ownership to a pending invitee. +2. In **Settings > General > Members**, open the actions menu on their row and choose **Make owner**. +3. Type their email address to confirm. + +What happens: + +- The transfer is immediate. The new owner doesn't need to accept it. +- You become a member. You lose access to SSO settings and member management, and only the new owner can transfer ownership back. +- We email both of you, and the change is recorded in the audit log. +- If the billing email on your subscription is your own address, it moves to the new owner. A different billing address, such as a finance inbox you set in the billing portal, stays as it is. + +Members cannot leave a workspace on their own yet. After the transfer, the new owner can remove you if you no longer need access. + +If the owner of your workspace is no longer reachable, contact us at [ping@openstatus.dev](mailto:ping@openstatus.dev). We transfer ownership after verifying the request. diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index 32e577799..f0a2ceca1 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -232,6 +232,10 @@ export const Events = { name: "user_removed", channel: "team", }, + TransferOwnership: { + name: "ownership_transferred", + channel: "team", + }, CreateAPI: { name: "api_key_created", channel: "api_key", diff --git a/packages/api/src/lib/after-response.ts b/packages/api/src/lib/after-response.ts new file mode 100644 index 000000000..9d1a48b23 --- /dev/null +++ b/packages/api/src/lib/after-response.ts @@ -0,0 +1,23 @@ +import { after } from "next/server.js"; + +/** + * Follow-ups that run after the response is sent. The mutation they follow + * is already committed, so a failure is only logged under `label`. Outside a + * request scope (tests, scripts) `after` throws and the task runs right away. + */ +export function createAfterResponse(label: string) { + return (task: () => Promise) => { + const run = async () => { + try { + await task(); + } catch (err) { + console.warn(`${label} failed`, err); + } + }; + try { + after(run); + } catch { + void run(); + } + }; +} diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts index 55369e938..ab50d628b 100644 --- a/packages/api/src/router/incident.ts +++ b/packages/api/src/router/incident.ts @@ -46,9 +46,9 @@ import { import { WebClient } from "@slack/web-api"; import { TRPCError } from "@trpc/server"; import { generateText } from "ai"; -import { after } from "next/server.js"; import { z } from "zod"; +import { createAfterResponse } from "../lib/after-response"; import { chatRateLimit } from "../lib/chat-rate-limit"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; @@ -61,15 +61,7 @@ const DASHBOARD_URL = resolveDashboardUrl({ type AuthedCtx = Parameters[0]; /** Follow-ups run after the response; the incident is already saved. */ -function afterResponse(task: () => Promise) { - const run = () => - task().catch((err) => console.warn("incident follow-up failed", err)); - try { - after(run); - } catch { - void run(); - } -} +const afterResponse = createAfterResponse("incident follow-up"); function effectsFor(ctx: AuthedCtx): IncidentEffects { return incidentEffects({ diff --git a/packages/api/src/router/member.ts b/packages/api/src/router/member.ts index 964339940..a46fb79ad 100644 --- a/packages/api/src/router/member.ts +++ b/packages/api/src/router/member.ts @@ -1,13 +1,19 @@ import { Events } from "@openstatus/analytics"; +import { sendOwnershipTransferred } from "@openstatus/emails"; import { ListMembersInput, deleteMember, listMembers, + transferOwnership, } from "@openstatus/services/member"; import { z } from "zod"; +import { createAfterResponse } from "../lib/after-response"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { syncCustomerEmailToNewOwner } from "./stripe/shared"; + +const afterResponse = createAfterResponse("ownership transfer follow-up"); export const memberRouter = createTRPCRouter({ list: protectedProcedure @@ -33,4 +39,46 @@ export const memberRouter = createTRPCRouter({ toTRPCError(err); } }), + + transferOwnership: protectedProcedure + .meta({ track: Events.TransferOwnership }) + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + let result: Awaited>; + try { + result = await transferOwnership({ + ctx: toServiceCtx(ctx), + input: { userId: input.id }, + }); + } catch (err) { + toTRPCError(err); + } + + // The transfer is committed; the follow-ups run after the response + // and only log on failure. + const { newOwnerEmail } = result; + const formerOwnerEmail = ctx.user.email || null; + const { workspace } = ctx; + + afterResponse(() => + sendOwnershipTransferred({ + idempotencyKey: `ownership:${workspace.id}:${result.toUserId}:${Date.now()}`, + workspaceName: workspace.name || workspace.slug, + newOwner: newOwnerEmail, + formerOwner: formerOwnerEmail || ctx.user.name || "A workspace owner", + to: { new: newOwnerEmail, former: formerOwnerEmail }, + }), + ); + + const { stripeId } = workspace; + if (stripeId && formerOwnerEmail) { + afterResponse(() => + syncCustomerEmailToNewOwner({ + customerId: stripeId, + formerOwnerEmail, + newOwnerEmail, + }), + ); + } + }), }); diff --git a/packages/api/src/router/stripe/shared.test.ts b/packages/api/src/router/stripe/shared.test.ts new file mode 100644 index 000000000..346b49796 --- /dev/null +++ b/packages/api/src/router/stripe/shared.test.ts @@ -0,0 +1,65 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; +import type Stripe from "stripe"; + +import { syncCustomerEmailToNewOwner } from "./shared"; + +function fakeClient(customer: { email?: string | null; deleted?: true }) { + const updates: Array<{ id: string; params: Stripe.CustomerUpdateParams }> = + []; + return { + updates, + client: { + customers: { + retrieve: async (_id: string) => + customer as Stripe.Customer | Stripe.DeletedCustomer, + update: async (id: string, params: Stripe.CustomerUpdateParams) => { + updates.push({ id, params }); + return customer as Stripe.Customer; + }, + }, + }, + }; +} + +describe("syncCustomerEmailToNewOwner", () => { + test("moves the email when it still equals the former owner's (case-insensitive)", async () => { + const { client, updates } = fakeClient({ email: "Max@Acme.dev" }); + const updated = await syncCustomerEmailToNewOwner({ + customerId: "cus_1", + formerOwnerEmail: "max@acme.dev", + newOwnerEmail: "web@acme.dev", + client, + }); + expect(updated).toBe(true); + expect(updates).toEqual([ + { id: "cus_1", params: { email: "web@acme.dev" } }, + ]); + }); + + test("keeps a different address, e.g. a finance inbox", async () => { + const { client, updates } = fakeClient({ email: "finance@acme.dev" }); + const updated = await syncCustomerEmailToNewOwner({ + customerId: "cus_1", + formerOwnerEmail: "max@acme.dev", + newOwnerEmail: "web@acme.dev", + client, + }); + expect(updated).toBe(false); + expect(updates).toHaveLength(0); + }); + + test("leaves a customer without email or a deleted customer alone", async () => { + for (const customer of [{ email: null }, { deleted: true as const }]) { + const { client, updates } = fakeClient(customer); + const updated = await syncCustomerEmailToNewOwner({ + customerId: "cus_1", + formerOwnerEmail: "max@acme.dev", + newOwnerEmail: "web@acme.dev", + client, + }); + expect(updated).toBe(false); + expect(updates).toHaveLength(0); + } + }); +}); diff --git a/packages/api/src/router/stripe/shared.ts b/packages/api/src/router/stripe/shared.ts index e6ccf88bf..0b1341463 100644 --- a/packages/api/src/router/stripe/shared.ts +++ b/packages/api/src/router/stripe/shared.ts @@ -211,3 +211,37 @@ export function trialEndsAtOf(subscription: Stripe.Subscription) { ? new Date(subscription.trial_end * 1000) : null; } + +type CustomerEmailClient = { + customers: { + retrieve(id: string): Promise; + update( + id: string, + params: Stripe.CustomerUpdateParams, + ): Promise; + }; +}; + +/** + * After an ownership transfer, move the Stripe customer email to the new + * owner, but only when it still equals the former owner's email. A finance + * address set in the portal stays. Returns whether the customer was updated. + */ +export async function syncCustomerEmailToNewOwner(args: { + customerId: string; + formerOwnerEmail: string; + newOwnerEmail: string; + client?: CustomerEmailClient; +}): Promise { + const client = args.client ?? stripe; + const customer = await client.customers.retrieve(args.customerId); + if (customer.deleted) return false; + const current = customer.email?.trim().toLowerCase(); + if (!current || current !== args.formerOwnerEmail.trim().toLowerCase()) { + return false; + } + await client.customers.update(args.customerId, { + email: args.newOwnerEmail, + }); + return true; +} diff --git a/packages/db/src/schema/audit_logs/validation.ts b/packages/db/src/schema/audit_logs/validation.ts index 573840487..ec7c4d0b7 100644 --- a/packages/db/src/schema/audit_logs/validation.ts +++ b/packages/db/src/schema/audit_logs/validation.ts @@ -206,6 +206,15 @@ const memberActions = [ // they're (userId, workspaceId) composite — so the user id is the most // useful identifier to fix on in the audit log. action("member.create", "member", intId, { optionalMetadata: true }), + // Only a role change today (ownership transfer). A transfer writes two rows, + // one per affected user, paired through this metadata, so it is required. + action("member.update", "member", intId).extend({ + metadata: z.object({ + reason: z.literal("ownership_transfer"), + fromUserId: intId, + toUserId: intId, + }), + }), action("member.delete", "member", intId), ] as const; diff --git a/packages/emails/emails/ownership-transferred.tsx b/packages/emails/emails/ownership-transferred.tsx new file mode 100644 index 000000000..3f31dda5d --- /dev/null +++ b/packages/emails/emails/ownership-transferred.tsx @@ -0,0 +1,73 @@ +/** @jsxRuntime automatic @jsxImportSource react */ + +import { Footer } from "./_components/footer"; +import { Heading } from "./_components/heading"; +import { KeyValue } from "./_components/key-value"; +import { Layout } from "./_components/layout"; + +export interface OwnershipTransferredProps { + workspaceName: string; + /** Email of the new owner. */ + newOwner: string; + /** Email of the former owner. */ + formerOwner: string; + recipient: "new" | "former"; +} + +export function ownershipTransferredSubject( + props: OwnershipTransferredProps, +): string { + return props.recipient === "new" + ? `You are now the owner of ${props.workspaceName} on openstatus` + : `You transferred ownership of ${props.workspaceName} on openstatus`; +} + +const OwnershipTransferredEmail = (props: OwnershipTransferredProps) => { + const isNew = props.recipient === "new"; + return ( + + } + > + {isNew ? ( + + You can manage members, SSO and billing for this workspace. Billing + emails from openstatus for it now go to you. + + ) : ( + + {`${props.newOwner} is now the owner and you are a member. If you did not do this, reply to this email.`} + + )} + + + ); +}; + +OwnershipTransferredEmail.PreviewProps = { + workspaceName: "Acme", + newOwner: "web@acme.dev", + formerOwner: "max@acme.dev", + recipient: "new", +} satisfies OwnershipTransferredProps; + +export default OwnershipTransferredEmail; diff --git a/packages/emails/src/billing.test.tsx b/packages/emails/src/billing.test.tsx index 6525a74c6..321c34c61 100644 --- a/packages/emails/src/billing.test.tsx +++ b/packages/emails/src/billing.test.tsx @@ -12,6 +12,7 @@ import MemberAccessEndingEmail, { memberAccessEndingSubject, } from "../emails/member-access-ending"; import MemberRemovedEmail from "../emails/member-removed"; +import OwnershipTransferredEmail from "../emails/ownership-transferred"; import PlanDowngradedEmail, { planDowngradedSubject, } from "../emails/plan-downgraded"; @@ -27,6 +28,7 @@ import { schedulePlanEndingSoon, sendCancellationScheduled, sendMemberRemoved, + sendOwnershipTransferred, sendPlanDowngraded, sendTrialEnding, } from "./billing"; @@ -179,6 +181,27 @@ describe("billing templates", () => { expect(manual).not.toContain("Ask for access"); }); + test("ownership-transferred: copy per recipient", async () => { + const props = { + workspaceName: "Acme", + newOwner: "web@acme.dev", + formerOwner: "max@acme.dev", + }; + const toNew = await render( + , + ); + expect(toNew).toContain("You are now the owner of Acme"); + expect(toNew).toContain("Transferred by"); + expect(toNew).toContain("max@acme.dev"); + + const toFormer = await render( + , + ); + expect(toFormer).toContain("You transferred ownership of Acme"); + expect(toFormer).toContain("you are a member"); + expect(toFormer).toContain("web@acme.dev"); + }); + test("cancellation-scheduled is raw: no layout, date, losses, resume link", async () => { const html = await render( { }); }); + test("sendOwnershipTransferred sends one mail per person, keyed by recipient", async () => { + await sendOwnershipTransferred({ + idempotencyKey: "ownership:1:42", + workspaceName: "Acme", + newOwner: "web@acme.dev", + formerOwner: "max@acme.dev", + to: { new: "web@acme.dev", former: "max@acme.dev" }, + }); + assertSpyCalls(send, 2); + const [toNew, newOptions] = send.calls[0].args; + const [toFormer, formerOptions] = send.calls[1].args; + expect(toNew.to).toEqual(["web@acme.dev"]); + expect(toNew.subject).toBe("You are now the owner of Acme on openstatus"); + expect(toNew.replyTo).toBe("ping@openstatus.dev"); + expect(newOptions).toEqual({ idempotencyKey: "ownership:1:42:new" }); + expect(toFormer.to).toEqual(["max@acme.dev"]); + expect(toFormer.subject).toBe( + "You transferred ownership of Acme on openstatus", + ); + expect(formerOptions).toEqual({ idempotencyKey: "ownership:1:42:former" }); + }); + + test("sendOwnershipTransferred skips a person without an address", async () => { + await sendOwnershipTransferred({ + idempotencyKey: "ownership:1:43", + workspaceName: "Acme", + newOwner: "web@acme.dev", + formerOwner: "Max", + to: { new: "web@acme.dev", former: null }, + }); + assertSpyCalls(send, 1); + const [toNew] = send.calls[0].args; + expect(toNew.to).toEqual(["web@acme.dev"]); + expect(toNew.react.props.formerOwner).toBe("Max"); + }); + test("sendCancellationScheduled is raw, from the root domain", async () => { await sendCancellationScheduled({ to: ["max@acme.dev"], diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx index cf964b9a6..84980e8c3 100644 --- a/packages/emails/src/billing.tsx +++ b/packages/emails/src/billing.tsx @@ -15,6 +15,10 @@ import MemberRemovedEmail, { type MemberRemovedProps, memberRemovedSubject, } from "../emails/member-removed"; +import OwnershipTransferredEmail, { + type OwnershipTransferredProps, + ownershipTransferredSubject, +} from "../emails/ownership-transferred"; import PlanDowngradedEmail, { type PlanDowngradedProps, planDowngradedSubject, @@ -162,6 +166,36 @@ export async function sendMemberRemoved( } } +/** + * Tells both people about an ownership transfer, one mail each. `newOwner` and + * `formerOwner` are what the mails show; `to` is where they go, and a person + * without an address is skipped. The key gets a `:new` / `:former` suffix so a + * retry cannot send either twice. + */ +export async function sendOwnershipTransferred( + req: Omit & { + to: { new: string | null; former: string | null }; + idempotencyKey: string; + }, +) { + const { to, idempotencyKey, ...props } = req; + for (const recipient of ["new", "former"] as const) { + const email = to[recipient]; + if (!email) continue; + const p = { ...props, recipient }; + await sendEmail( + { + from: SYSTEM_FROM, + reply_to: SUPPORT_EMAIL, + to: [email], + subject: ownershipTransferredSubject(p), + react: , + }, + { idempotencyKey: `${idempotencyKey}:${recipient}` }, + ); + } +} + /** * Warns members `REMINDER_LEAD_DAYS` before `endsAt`, or right away when that * cannot be scheduled or `schedule` is false. Returns the ids of the scheduled diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts index 0313243a4..0cbd74281 100644 --- a/packages/emails/src/index.ts +++ b/packages/emails/src/index.ts @@ -24,6 +24,7 @@ export { schedulePlanEndingSoon, sendCancellationScheduled, sendMemberRemoved, + sendOwnershipTransferred, sendPlanDowngraded, sendTrialEnding, stripeIdempotencyKey, diff --git a/packages/services/src/member/__tests__/transfer-ownership.test.ts b/packages/services/src/member/__tests__/transfer-ownership.test.ts new file mode 100644 index 000000000..643962ffb --- /dev/null +++ b/packages/services/src/member/__tests__/transfer-ownership.test.ts @@ -0,0 +1,411 @@ +import { and, db, eq } from "@openstatus/db"; +import { user, usersToWorkspaces } from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; + +import { + clearAuditLogFor, + createWorkspaceFixture, + expectAuditRow, + makeApiKeyCtx, + makeSystemCtx, + makeUserCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { DB, ServiceContext } from "../../context"; +import { + ConflictError, + ForbiddenError, + NotFoundError, + PreconditionFailedError, +} from "../../errors"; +import { deleteAccount } from "../../user"; +import { downgradeWorkspaceToFree, listWorkspaceOwners } from "../../workspace"; +import { transferOwnership } from "../index.ts"; +import { transferOwnershipInWorkspace } from "../transfer-ownership"; + +let teamCtx: ServiceContext; +let otherWorkspace: ServiceContext["workspace"]; +let OWNER_USER_ID: number; +let TARGET_USER_ID: number; +let OTHER_MEMBER_USER_ID: number; +let DELETED_USER_ID: number; +let OUTSIDER_USER_ID: number; + +beforeAll(async () => { + const team = await createWorkspaceFixture("team"); + OWNER_USER_ID = team.userId; + teamCtx = makeUserCtx(team.workspace, { userId: OWNER_USER_ID }); + + TARGET_USER_ID = (await createUser()).id; + OTHER_MEMBER_USER_ID = (await createUser()).id; + DELETED_USER_ID = (await createUser({ deletedAt: new Date() })).id; + await addUserToWorkspace(TARGET_USER_ID, team.workspace.id, "member"); + await addUserToWorkspace(OTHER_MEMBER_USER_ID, team.workspace.id, "member"); + await addUserToWorkspace(DELETED_USER_ID, team.workspace.id, "member"); + + // A member of a different workspace only. + const other = await createWorkspaceFixture("team"); + otherWorkspace = other.workspace; + OUTSIDER_USER_ID = (await createUser()).id; + await addUserToWorkspace(OUTSIDER_USER_ID, otherWorkspace.id, "member"); +}); + +afterAll(async () => { + await clearAuditLogFor({ + entityType: "member", + entityIds: [ + OWNER_USER_ID, + TARGET_USER_ID, + OTHER_MEMBER_USER_ID, + DELETED_USER_ID, + OUTSIDER_USER_ID, + ], + }); +}); + +async function roleOf(tx: DB, workspaceId: number, userId: number) { + const row = await tx + .select({ role: usersToWorkspaces.role }) + .from(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.workspaceId, workspaceId), + eq(usersToWorkspaces.userId, userId), + ), + ) + .get(); + return row?.role; +} + +async function expectRejects( + promise: Promise, + ErrorClass: new (...args: never[]) => Error, +) { + let thrown: unknown; + try { + await promise; + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(ErrorClass); +} + +describe("transferOwnership", () => { + test("target becomes owner, caller becomes member", async () => { + await withTestTransaction(async (tx) => { + const result = await transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: TARGET_USER_ID }, + }); + + expect(result).toMatchObject({ + fromUserId: OWNER_USER_ID, + toUserId: TARGET_USER_ID, + formerOwnerRole: "member", + }); + expect(result.newOwnerEmail).toBeTruthy(); + expect(await roleOf(tx, teamCtx.workspace.id, TARGET_USER_ID)).toBe( + "owner", + ); + expect(await roleOf(tx, teamCtx.workspace.id, OWNER_USER_ID)).toBe( + "member", + ); + + const owners = await listWorkspaceOwners({ + input: { workspaceId: teamCtx.workspace.id }, + db: tx, + }); + expect(owners.map((o) => o.id)).toEqual([TARGET_USER_ID]); + }); + }); + + test("writes paired member.update audit rows", async () => { + await withTestTransaction(async (tx) => { + await transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: TARGET_USER_ID }, + }); + + const expected = [ + { userId: TARGET_USER_ID, before: "member", after: "owner" }, + { userId: OWNER_USER_ID, before: "owner", after: "member" }, + ]; + for (const { userId, before, after } of expected) { + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "member.update", + entityType: "member", + entityId: userId, + actorType: "user", + db: tx, + }); + const [row] = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "member", + entityId: userId, + db: tx, + }); + expect(row.action).toBe("member.update"); + expect(row.changedFields).toEqual(["role"]); + expect(row.before?.role).toBe(before); + expect(row.after?.role).toBe(after); + expect(row.metadata).toEqual({ + reason: "ownership_transfer", + fromUserId: OWNER_USER_ID, + toUserId: TARGET_USER_ID, + }); + } + }); + }); + + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const readOnlyCtx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k-read", + userId: OWNER_USER_ID, + scopes: ["read"], + }), + db: tx, + }; + await expect( + transferOwnership({ ctx: readOnlyCtx, input: { userId: 999_999_999 } }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("system actor is rejected on the public verb", async () => { + await withTestTransaction(async (tx) => { + await expectRejects( + transferOwnership({ + ctx: { ...makeSystemCtx(teamCtx.workspace, { job: "test" }), db: tx }, + input: { userId: TARGET_USER_ID }, + }), + PreconditionFailedError, + ); + }); + }); + + test("non-owner caller is rejected", async () => { + await withTestTransaction(async (tx) => { + await expectRejects( + transferOwnership({ + ctx: { + ...makeUserCtx(teamCtx.workspace, { userId: OTHER_MEMBER_USER_ID }), + db: tx, + }, + input: { userId: TARGET_USER_ID }, + }), + PreconditionFailedError, + ); + expect(await roleOf(tx, teamCtx.workspace.id, TARGET_USER_ID)).toBe( + "member", + ); + }); + }); + + test("self-target is rejected", async () => { + await withTestTransaction(async (tx) => { + await expectRejects( + transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: OWNER_USER_ID }, + }), + PreconditionFailedError, + ); + }); + }); + + test("non-member target surfaces NOT_FOUND", async () => { + await withTestTransaction(async (tx) => { + await expectRejects( + transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: 9_876_543 }, + }), + NotFoundError, + ); + }); + }); + + test("soft-deleted target is rejected", async () => { + await withTestTransaction(async (tx) => { + await expectRejects( + transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: DELETED_USER_ID }, + }), + PreconditionFailedError, + ); + expect(await roleOf(tx, teamCtx.workspace.id, OWNER_USER_ID)).toBe( + "owner", + ); + }); + }); + + test("target already owner is a conflict", async () => { + await withTestTransaction(async (tx) => { + await tx + .update(usersToWorkspaces) + .set({ role: "owner" }) + .where( + and( + eq(usersToWorkspaces.workspaceId, teamCtx.workspace.id), + eq(usersToWorkspaces.userId, TARGET_USER_ID), + ), + ); + await expectRejects( + transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: TARGET_USER_ID }, + }), + ConflictError, + ); + }); + }); + + test("target who is a member of another workspace only surfaces NOT_FOUND", async () => { + await withTestTransaction(async (tx) => { + await expectRejects( + transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: OUTSIDER_USER_ID }, + }), + NotFoundError, + ); + expect(await roleOf(tx, teamCtx.workspace.id, OWNER_USER_ID)).toBe( + "owner", + ); + expect(await roleOf(tx, otherWorkspace.id, OUTSIDER_USER_ID)).toBe( + "member", + ); + }); + }); +}); + +describe("transferOwnershipInWorkspace", () => { + test("demote predicate mismatch is a conflict and rolls back the promote", async () => { + // One top-level transaction (libSQL HTTP has no savepoints): the throw + // rolls back the setup demote and the promote together. + await expectRejects( + db.transaction(async (tx) => { + // The former owner was already demoted by a concurrent request. + await tx + .update(usersToWorkspaces) + .set({ role: "member" }) + .where( + and( + eq(usersToWorkspaces.workspaceId, teamCtx.workspace.id), + eq(usersToWorkspaces.userId, OWNER_USER_ID), + ), + ); + return transferOwnershipInWorkspace({ + tx, + ctx: { ...teamCtx, db: tx }, + fromUserId: OWNER_USER_ID, + toUserId: TARGET_USER_ID, + demoteTo: "member", + }); + }), + ConflictError, + ); + + expect(await roleOf(db, teamCtx.workspace.id, TARGET_USER_ID)).toBe( + "member", + ); + expect(await roleOf(db, teamCtx.workspace.id, OWNER_USER_ID)).toBe("owner"); + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "member", + entityId: TARGET_USER_ID, + db, + }); + expect(rows.filter((r) => r.action === "member.update")).toHaveLength(0); + }); + + test("system actor (support path) transfers and is attributed as system", async () => { + await withTestTransaction(async (tx) => { + const sysCtx = { + ...makeSystemCtx(teamCtx.workspace, { job: "support-transfer" }), + db: tx, + }; + await transferOwnershipInWorkspace({ + tx, + ctx: sysCtx, + fromUserId: OWNER_USER_ID, + toUserId: TARGET_USER_ID, + demoteTo: "member", + }); + + expect(await roleOf(tx, teamCtx.workspace.id, TARGET_USER_ID)).toBe( + "owner", + ); + for (const userId of [TARGET_USER_ID, OWNER_USER_ID]) { + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "member.update", + entityType: "member", + entityId: userId, + actorType: "system", + db: tx, + }); + } + }); + }); +}); + +describe("after a transfer", () => { + test("the former owner of a paid workspace can delete their account", async () => { + await withTestTransaction(async (tx) => { + await transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: TARGET_USER_ID }, + }); + + await deleteAccount({ + ctx: { + ...makeUserCtx(teamCtx.workspace, { userId: OWNER_USER_ID }), + db: tx, + }, + }); + + const deleted = await tx + .select({ deletedAt: user.deletedAt }) + .from(user) + .where(eq(user.id, OWNER_USER_ID)) + .get(); + expect(deleted?.deletedAt).toBeTruthy(); + }); + }); + + test("downgrade keeps the new owner and removes the former one", async () => { + await withTestTransaction(async (tx) => { + await transferOwnership({ + ctx: { ...teamCtx, db: tx }, + input: { userId: TARGET_USER_ID }, + }); + + await downgradeWorkspaceToFree({ + ctx: { + ...makeSystemCtx(teamCtx.workspace, { + job: "stripe-subscription-deleted", + }), + db: tx, + }, + }); + + expect(await roleOf(tx, teamCtx.workspace.id, TARGET_USER_ID)).toBe( + "owner", + ); + expect( + await roleOf(tx, teamCtx.workspace.id, OWNER_USER_ID), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/services/src/member/index.ts b/packages/services/src/member/index.ts index 6582ab10e..8fea4724e 100644 --- a/packages/services/src/member/index.ts +++ b/packages/services/src/member/index.ts @@ -2,9 +2,11 @@ export { deleteMember } from "./delete"; export { getMemberDisplayName } from "./display-name"; export { findMemberIdByEmail } from "./find-by-email"; export { listMembers, type Member } from "./list"; +export { transferOwnership } from "./transfer-ownership"; export { DeleteMemberInput, FindMemberByEmailInput, GetMemberDisplayNameInput, ListMembersInput, + TransferOwnershipInput, } from "./schemas"; diff --git a/packages/services/src/member/schemas.ts b/packages/services/src/member/schemas.ts index db5605c44..cf8bdf0de 100644 --- a/packages/services/src/member/schemas.ts +++ b/packages/services/src/member/schemas.ts @@ -6,6 +6,9 @@ export type ListMembersInput = z.infer; export const DeleteMemberInput = z.object({ userId: z.number().int() }); export type DeleteMemberInput = z.infer; +export const TransferOwnershipInput = z.object({ userId: z.number().int() }); +export type TransferOwnershipInput = z.infer; + export const FindMemberByEmailInput = z.object({ email: z.string() }); export type FindMemberByEmailInput = z.infer; diff --git a/packages/services/src/member/transfer-ownership.ts b/packages/services/src/member/transfer-ownership.ts new file mode 100644 index 000000000..adcc22d4b --- /dev/null +++ b/packages/services/src/member/transfer-ownership.ts @@ -0,0 +1,200 @@ +import { and, eq, ne } from "@openstatus/db"; +import { user, usersToWorkspaces } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type DB, type ServiceContext, withTransaction } from "../context"; +import { + ConflictError, + NotFoundError, + PreconditionFailedError, +} from "../errors"; +import { memberRowSnapshot } from "./internal"; +import { TransferOwnershipInput } from "./schemas"; + +type DemoteRole = "admin" | "member"; + +export type TransferOwnershipResult = { + fromUserId: number; + toUserId: number; + /** For the post-commit emails and the Stripe customer email sync. */ + newOwnerEmail: string; + formerOwnerRole: DemoteRole; +}; + +/** + * Hand ownership of the caller's workspace to another existing member. + * Immediate, no acceptance step: the target is already a trusted member. + * The caller is demoted to `member`. + * + * Only fires when the actor is an openstatus user, like `deleteMember`. + * Support transfers for orphaned workspaces go through + * `transferOwnershipInWorkspace` with a `system` actor instead. + */ +// The role updates and their `member.update` audit rows live in +// `transferOwnershipInWorkspace`; this verb only adds the owner / self guards. +// oxlint-disable-next-line openstatus/services-mutation-guards +export async function transferOwnership(args: { + ctx: ServiceContext; + input: TransferOwnershipInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = TransferOwnershipInput.parse(args.input); + + if (ctx.actor.type !== "user") { + throw new PreconditionFailedError( + "Only user actors can transfer workspace ownership", + ); + } + const callerId = ctx.actor.userId; + + return withTransaction(ctx, async (tx) => { + const caller = await tx.query.usersToWorkspaces.findFirst({ + where: and( + eq(usersToWorkspaces.userId, callerId), + eq(usersToWorkspaces.workspaceId, ctx.workspace.id), + ), + }); + + if (!caller) { + throw new NotFoundError("membership", callerId); + } + + if (caller.role !== "owner") { + throw new PreconditionFailedError( + "Only the workspace owner can transfer ownership", + ); + } + + if (input.userId === callerId) { + throw new PreconditionFailedError( + "Cannot transfer ownership to yourself", + ); + } + + return transferOwnershipInWorkspace({ + tx, + ctx, + fromUserId: callerId, + toUserId: input.userId, + demoteTo: "member", + }); + }); +} + +/** + * Shared promote/demote+audit body for an ownership transfer. Not + * re-exported from the entity index: the public verb and the support + * script are its only callers. + * + * No authorization guard of its own — callers own that. Promotes before it + * demotes, so the workspace never passes through a zero-owner state even + * inside the transaction. The demote is conditional on `role = 'owner'`: + * two concurrent transfers by the same owner resolve to one winner, the + * other throws `ConflictError` and its promote rolls back. + * + * Other owners (legacy multi-owner workspaces) are left untouched. + */ +export async function transferOwnershipInWorkspace(args: { + tx: DB; + ctx: ServiceContext; + fromUserId: number; + toUserId: number; + demoteTo: DemoteRole; +}): Promise { + const { tx, ctx, fromUserId, toUserId, demoteTo } = args; + const workspaceId = ctx.workspace.id; + + const target = await tx + .select({ + role: usersToWorkspaces.role, + email: user.email, + deletedAt: user.deletedAt, + }) + .from(usersToWorkspaces) + .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) + .where( + and( + eq(usersToWorkspaces.userId, toUserId), + eq(usersToWorkspaces.workspaceId, workspaceId), + ), + ) + .get(); + + if (!target) { + throw new NotFoundError("membership", toUserId); + } + if (target.deletedAt || !target.email) { + throw new PreconditionFailedError("This user cannot own a workspace"); + } + if (target.role === "owner") { + throw new ConflictError("User is already an owner of this workspace"); + } + + const [promoted] = await tx + .update(usersToWorkspaces) + .set({ role: "owner" }) + .where( + and( + eq(usersToWorkspaces.userId, toUserId), + eq(usersToWorkspaces.workspaceId, workspaceId), + ne(usersToWorkspaces.role, "owner"), + ), + ) + .returning(); + + if (!promoted) { + throw new ConflictError("User is already an owner of this workspace"); + } + + const [demoted] = await tx + .update(usersToWorkspaces) + .set({ role: demoteTo }) + .where( + and( + eq(usersToWorkspaces.userId, fromUserId), + eq(usersToWorkspaces.workspaceId, workspaceId), + eq(usersToWorkspaces.role, "owner"), + ), + ) + .returning(); + + if (!demoted) { + // Another request already demoted the former owner; throwing rolls back + // the promote above. + throw new ConflictError("Ownership was changed by another request"); + } + + const metadata = { + reason: "ownership_transfer" as const, + fromUserId, + toUserId, + }; + + // New owner first, former owner second: reads sort newest first, so the + // former owner's row shows on top. + await emitAudit(tx, ctx, { + action: "member.update", + entityType: "member", + entityId: toUserId, + before: memberRowSnapshot.parse({ ...promoted, role: target.role }), + after: memberRowSnapshot.parse(promoted), + metadata, + }); + await emitAudit(tx, ctx, { + action: "member.update", + entityType: "member", + entityId: fromUserId, + before: memberRowSnapshot.parse({ ...demoted, role: "owner" }), + after: memberRowSnapshot.parse(demoted), + metadata, + }); + + return { + fromUserId, + toUserId, + newOwnerEmail: target.email, + formerOwnerRole: demoteTo, + }; +} -- 2.54.0