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 (
+
+ );
+}
+
+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 ?? (
Delete
)}
-
-
-
-
+
+
+
+
Are you sure about delete `{confirmationValue}`?
-
-
+
+
This action cannot be undone. This will permanently delete the item.
-
-
-
-
- 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