Merge pull request #170 from ArnasDon/feat/account-sharing-members-api

feat(api): account + members management endpoints
This commit is contained in:
Arnas Donauskas 2026-05-31 14:09:06 +02:00 committed by GitHub
commit dabddcfa05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 652 additions and 0 deletions

View File

@ -38,6 +38,25 @@ as the sole owner of their own account and sees identical data.
- **The signup trigger (`handle_new_user`) now also creates a - **The signup trigger (`handle_new_user`) now also creates a
personal account** and links the new profile to it as `owner`. personal account** and links the new profile to it as `owner`.
### Added
- **Account & member management API** — server-side endpoints
for the upcoming Members tab UI. All routes are role-gated and
return Supabase-RLS-scoped data.
- `GET /api/account` — caller's account + role. Any member.
- `PATCH /api/account` — rename the account. Admin+.
- `GET /api/account/members` — list members. Email visible to
admin+ only; agents/viewers see name + avatar + role +
joined date.
- `PATCH /api/account/members/[userId]` — change a member's
role. Admin+. Owner promotion/demotion goes through the
transfer endpoint instead.
- `DELETE /api/account/members/[userId]` — remove a member.
Admin+. The removed user keeps their login and is moved to a
freshly-created personal account (mirror of the signup flow).
- `POST /api/account/transfer-ownership` — owner only. Atomic
swap with the named member.
### Migration required ### Migration required
Apply against your Supabase project before deploying this version: Apply against your Supabase project before deploying this version:
@ -50,6 +69,13 @@ Apply against your Supabase project before deploying this version:
every existing user is mapped to a freshly-created account every existing user is mapped to a freshly-created account
with role `owner` and every existing row of theirs is linked with role `owner` and every existing row of theirs is linked
to that account. to that account.
- `supabase/migrations/018_account_member_rpcs.sql` — adds three
`SECURITY DEFINER` RPCs (`set_member_role`,
`remove_account_member`, `transfer_account_ownership`) that
back the member-management API. They self-check the caller's
role and raise SQLSTATE `42501` / `22023` on forbidden / bad
input so the API layer can map cleanly to 403 / 400.
Idempotent.
## [0.2.2] — 2026-05-29 ## [0.2.2] — 2026-05-29

View File

@ -0,0 +1,103 @@
// ============================================================
// /api/account/members/[userId]
//
// PATCH — change a member's role. Admin+.
// DELETE — remove a member. Admin+.
//
// Both delegate to SECURITY DEFINER RPCs from migration 018:
// - set_member_role(p_user_id, p_new_role)
// - remove_account_member(p_user_id)
//
// The RPCs do the *real* authorisation work — caller must be
// admin+, target must be in caller's account, target can't be the
// owner, can't be self. The TS layer here only forwards the call
// and maps Postgres SQLSTATEs back to HTTP statuses.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import { isAccountRole } from "@/lib/auth/roles";
// Map known SQLSTATEs from the RPCs (see migration 018) onto HTTP
// statuses. The `error.code` field is the SQLSTATE; the `message`
// is the human-readable RAISE message we put in the migration.
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 403 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error("[members route] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to update member" },
{ status: 500 },
);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ userId: string }> },
) {
try {
const ctx = await requireRole("admin");
const { userId } = await params;
const body = (await request.json().catch(() => null)) as
| { role?: unknown }
| null;
const role = body?.role;
if (!isAccountRole(role)) {
return NextResponse.json(
{ error: "'role' must be one of owner, admin, agent, viewer" },
{ status: 400 },
);
}
// The RPC blocks promotion to / demotion from owner, but
// surface the friendlier 400 before crossing the wire too.
if (role === "owner") {
return NextResponse.json(
{
error:
"Use POST /api/account/transfer-ownership to promote a member to owner",
},
{ status: 400 },
);
}
const { error } = await ctx.supabase.rpc("set_member_role", {
p_user_id: userId,
p_new_role: role,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ userId: string }> },
) {
try {
const ctx = await requireRole("admin");
const { userId } = await params;
const { data, error } = await ctx.supabase.rpc("remove_account_member", {
p_user_id: userId,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true, newPersonalAccountId: data });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,73 @@
// ============================================================
// GET /api/account/members
//
// Lists every member of the caller's account. Any member can call
// it (the Members tab is shown to admins+, but agents/viewers see
// a read-only roster too).
//
// Field visibility
// Sensitive fields (email) are returned only when the caller is
// admin+. Agents and viewers see name + avatar + role + joined
// date only. This mirrors the design decision from the planning
// phase: "agent/viewer sees names only".
// ============================================================
import { NextResponse } from "next/server";
import { getCurrentAccount, toErrorResponse } from "@/lib/auth/account";
import { canManageMembers, isAccountRole } from "@/lib/auth/roles";
import type { AccountMember } from "@/types";
interface ProfileRow {
user_id: string;
full_name: string | null;
email: string | null;
avatar_url: string | null;
account_role: string;
created_at: string;
}
export async function GET() {
try {
const ctx = await getCurrentAccount();
// RLS on profiles allows reading any row whose account matches
// the caller's, so this query is naturally account-scoped.
const { data, error } = await ctx.supabase
.from("profiles")
.select("user_id, full_name, email, avatar_url, account_role, created_at")
.eq("account_id", ctx.accountId)
.order("created_at", { ascending: true });
if (error) {
console.error("[GET /api/account/members] fetch error:", error);
return NextResponse.json(
{ error: "Failed to load members" },
{ status: 500 },
);
}
const canSeeEmails = canManageMembers(ctx.role);
const members: AccountMember[] = (data as ProfileRow[]).flatMap((row) => {
// Defensive: the DB enum should never let an unknown role
// through, but if a migration ever broadens the enum without
// updating TS, skip the row rather than crash the page.
if (!isAccountRole(row.account_role)) return [];
return [
{
user_id: row.user_id,
full_name: row.full_name ?? "",
email: canSeeEmails ? row.email : null,
avatar_url: row.avatar_url,
role: row.account_role,
joined_at: row.created_at,
},
];
});
return NextResponse.json({ members });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,88 @@
// ============================================================
// /api/account
//
// GET — current caller's account + role. Any member.
// PATCH — rename the account. Admin+.
//
// Why both verbs share a route file
// They speak about the same singular resource (the caller's
// account) and reuse the same `requireRole` plumbing. Splitting
// them across files would duplicate the `account_id` lookup
// without buying anything.
// ============================================================
import { NextResponse } from "next/server";
import {
requireRole,
getCurrentAccount,
toErrorResponse,
} from "@/lib/auth/account";
export async function GET() {
try {
const ctx = await getCurrentAccount();
return NextResponse.json({
account: ctx.account,
role: ctx.role,
});
} catch (err) {
return toErrorResponse(err);
}
}
const MAX_NAME_LEN = 80;
export async function PATCH(request: Request) {
try {
const ctx = await requireRole("admin");
const body = (await request.json().catch(() => null)) as
| { name?: unknown }
| null;
const rawName = body?.name;
if (typeof rawName !== "string") {
return NextResponse.json(
{ error: "'name' must be a string" },
{ status: 400 },
);
}
const name = rawName.trim();
if (name.length === 0) {
return NextResponse.json(
{ error: "Account name cannot be empty" },
{ status: 400 },
);
}
if (name.length > MAX_NAME_LEN) {
return NextResponse.json(
{ error: `Account name must be ${MAX_NAME_LEN} characters or fewer` },
{ status: 400 },
);
}
// RLS allows this UPDATE because accounts_update requires
// `is_account_member(id, 'admin')`, and requireRole already
// guaranteed the caller is admin+.
const { data, error } = await ctx.supabase
.from("accounts")
.update({ name })
.eq("id", ctx.accountId)
.select("id, name")
.single();
if (error) {
console.error("[PATCH /api/account] update error:", error);
return NextResponse.json(
{ error: "Failed to update account" },
{ status: 500 },
);
}
return NextResponse.json({ account: data });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,79 @@
// ============================================================
// POST /api/account/transfer-ownership
//
// Owner only. Atomically:
// - demotes the current owner to 'admin'
// - promotes the target member to 'owner'
// - updates accounts.owner_user_id
//
// The atomic part lives in the `transfer_account_ownership`
// SECURITY DEFINER RPC (migration 018). This route just validates
// shape and forwards.
//
// Why a separate endpoint instead of PATCH /members/[userId]?
// The semantics differ: transfer demotes the current owner as
// a side-effect and changes the owner_user_id pointer on
// `accounts`. Making it explicit prevents the "I clicked the
// role dropdown by mistake" failure mode where an admin would
// silently hand their account away.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 403 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error("[transfer-ownership] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to transfer ownership" },
{ status: 500 },
);
}
// Crude shape check — full UUID validation happens DB-side when
// the FK / lookup runs. This guards against obviously-wrong input
// (numbers, objects) before we round-trip.
function looksLikeUuid(v: unknown): v is string {
return (
typeof v === "string" &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
);
}
export async function POST(request: Request) {
try {
// `requireRole('owner')` is belt-and-braces — the RPC checks
// this too, but failing fast here saves a Supabase round trip
// on the obvious "admin trying to transfer" case.
const ctx = await requireRole("owner");
const body = (await request.json().catch(() => null)) as
| { newOwnerUserId?: unknown }
| null;
const newOwnerUserId = body?.newOwnerUserId;
if (!looksLikeUuid(newOwnerUserId)) {
return NextResponse.json(
{ error: "'newOwnerUserId' must be a valid UUID" },
{ status: 400 },
);
}
const { error } = await ctx.supabase.rpc("transfer_account_ownership", {
p_new_owner_user_id: newOwnerUserId,
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,283 @@
-- ============================================================
-- 018_account_member_rpcs.sql — RPCs for member management
--
-- Why RPCs and not direct UPDATEs from the client
--
-- The `profiles_update` RLS policy from migration 017 only
-- allows a user to update their *own* profile row. That is
-- correct for self-service edits (name, avatar) but it would
-- block an admin from changing a teammate's role or moving
-- a removed member to a fresh personal account.
--
-- These three SECURITY DEFINER functions are the supervised
-- escape hatches: they bypass RLS to do exactly the writes the
-- matching API route needs, but every function self-checks the
-- caller's authority via `auth.uid()` first, so the privilege
-- bypass is scoped tightly.
--
-- Error contract
--
-- All functions raise Postgres exceptions with these SQLSTATEs:
-- 42501 ("insufficient_privilege") — forbidden
-- 22023 ("invalid_parameter_value") — bad input / 400
-- The `toErrorResponse` helper on the API side maps each to
-- the right HTTP status, with the RAISE message surfaced to
-- the caller.
--
-- Idempotent — safe to run multiple times.
-- ============================================================
-- ============================================================
-- set_member_role(p_user_id, p_new_role)
--
-- Admin+ changes another member's role within the caller's
-- account. Cannot promote to / demote from 'owner' (that is the
-- transfer endpoint). Cannot target self.
-- ============================================================
CREATE OR REPLACE FUNCTION public.set_member_role(
p_user_id UUID,
p_new_role account_role_enum
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_account_id UUID;
v_caller_role account_role_enum;
v_target_account_id UUID;
v_target_role account_role_enum;
BEGIN
-- Caller must be authenticated.
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
END IF;
-- Resolve caller's account + role.
SELECT account_id, account_role
INTO v_caller_account_id, v_caller_role
FROM profiles
WHERE user_id = auth.uid();
IF v_caller_account_id IS NULL THEN
RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501';
END IF;
-- Caller must be admin+.
IF v_caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'This action requires the admin role or higher'
USING ERRCODE = '42501';
END IF;
-- Can't change own role via this endpoint.
IF p_user_id = auth.uid() THEN
RAISE EXCEPTION 'Cannot change your own role'
USING ERRCODE = '22023';
END IF;
-- Resolve target.
SELECT account_id, account_role
INTO v_target_account_id, v_target_role
FROM profiles
WHERE user_id = p_user_id;
IF v_target_account_id IS NULL THEN
RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023';
END IF;
-- Target must be in caller's account.
IF v_target_account_id <> v_caller_account_id THEN
RAISE EXCEPTION 'Target user is not a member of your account'
USING ERRCODE = '42501';
END IF;
-- Owner role changes go through transfer_account_ownership.
IF v_target_role = 'owner' THEN
RAISE EXCEPTION 'Use transfer_account_ownership to demote an owner'
USING ERRCODE = '22023';
END IF;
IF p_new_role = 'owner' THEN
RAISE EXCEPTION 'Use transfer_account_ownership to promote to owner'
USING ERRCODE = '22023';
END IF;
UPDATE profiles
SET account_role = p_new_role
WHERE user_id = p_user_id;
END;
$$;
ALTER FUNCTION public.set_member_role(UUID, account_role_enum) OWNER TO postgres;
REVOKE ALL ON FUNCTION public.set_member_role(UUID, account_role_enum) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.set_member_role(UUID, account_role_enum) TO authenticated;
-- ============================================================
-- remove_account_member(p_user_id)
--
-- Admin+ removes another member from the caller's account. The
-- removed user is NOT deleted from auth.users — they keep their
-- login. Instead, a fresh personal account is created on the fly
-- and their profile is reassigned to it as 'owner'. This is the
-- mirror image of the signup trigger: the user effectively
-- "starts over" with an empty account, free to invite their own
-- teammates if they want.
--
-- Cannot target the owner. Cannot target self.
-- ============================================================
CREATE OR REPLACE FUNCTION public.remove_account_member(
p_user_id UUID
) RETURNS UUID -- the new personal account id
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_account_id UUID;
v_caller_role account_role_enum;
v_target_account_id UUID;
v_target_role account_role_enum;
v_target_name TEXT;
v_target_email TEXT;
v_new_account_id UUID;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
END IF;
SELECT account_id, account_role
INTO v_caller_account_id, v_caller_role
FROM profiles
WHERE user_id = auth.uid();
IF v_caller_account_id IS NULL THEN
RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501';
END IF;
IF v_caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'This action requires the admin role or higher'
USING ERRCODE = '42501';
END IF;
IF p_user_id = auth.uid() THEN
RAISE EXCEPTION 'Cannot remove yourself; transfer ownership or leave the account instead'
USING ERRCODE = '22023';
END IF;
SELECT account_id, account_role, full_name, email
INTO v_target_account_id, v_target_role, v_target_name, v_target_email
FROM profiles
WHERE user_id = p_user_id;
IF v_target_account_id IS NULL THEN
RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023';
END IF;
IF v_target_account_id <> v_caller_account_id THEN
RAISE EXCEPTION 'Target user is not a member of your account'
USING ERRCODE = '42501';
END IF;
IF v_target_role = 'owner' THEN
RAISE EXCEPTION 'Cannot remove the account owner; transfer ownership first'
USING ERRCODE = '22023';
END IF;
-- Spin up a fresh personal account for the removed user. Mirror
-- of handle_new_user's logic — keep them whole, just relocated.
INSERT INTO accounts (name, owner_user_id)
VALUES (
COALESCE(NULLIF(v_target_name, ''), v_target_email, 'My account'),
p_user_id
)
RETURNING id INTO v_new_account_id;
UPDATE profiles
SET account_id = v_new_account_id,
account_role = 'owner'
WHERE user_id = p_user_id;
RETURN v_new_account_id;
END;
$$;
ALTER FUNCTION public.remove_account_member(UUID) OWNER TO postgres;
REVOKE ALL ON FUNCTION public.remove_account_member(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.remove_account_member(UUID) TO authenticated;
-- ============================================================
-- transfer_account_ownership(p_new_owner_user_id)
--
-- Owner only. Atomically:
-- - demotes the current owner to 'admin'
-- - promotes the target to 'owner'
-- - updates accounts.owner_user_id
--
-- Both writes happen in the same statement-level transaction.
-- ============================================================
CREATE OR REPLACE FUNCTION public.transfer_account_ownership(
p_new_owner_user_id UUID
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_account_id UUID;
v_caller_role account_role_enum;
v_target_account_id UUID;
v_target_role account_role_enum;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
END IF;
SELECT account_id, account_role
INTO v_caller_account_id, v_caller_role
FROM profiles
WHERE user_id = auth.uid();
IF v_caller_account_id IS NULL THEN
RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501';
END IF;
IF v_caller_role <> 'owner' THEN
RAISE EXCEPTION 'Only the account owner can transfer ownership'
USING ERRCODE = '42501';
END IF;
IF p_new_owner_user_id = auth.uid() THEN
RAISE EXCEPTION 'You are already the owner'
USING ERRCODE = '22023';
END IF;
SELECT account_id, account_role
INTO v_target_account_id, v_target_role
FROM profiles
WHERE user_id = p_new_owner_user_id;
IF v_target_account_id IS NULL THEN
RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023';
END IF;
IF v_target_account_id <> v_caller_account_id THEN
RAISE EXCEPTION 'Target user is not a member of your account'
USING ERRCODE = '42501';
END IF;
-- Demote current owner first so the temporary state where the
-- account has zero owners is never visible — both writes happen
-- in the same function transaction.
UPDATE profiles SET account_role = 'admin'
WHERE user_id = auth.uid();
UPDATE profiles SET account_role = 'owner'
WHERE user_id = p_new_owner_user_id;
UPDATE accounts SET owner_user_id = p_new_owner_user_id
WHERE id = v_caller_account_id;
END;
$$;
ALTER FUNCTION public.transfer_account_ownership(UUID) OWNER TO postgres;
REVOKE ALL ON FUNCTION public.transfer_account_ownership(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.transfer_account_ownership(UUID) TO authenticated;