mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #170 from ArnasDon/feat/account-sharing-members-api
feat(api): account + members management endpoints
This commit is contained in:
commit
dabddcfa05
26
CHANGELOG.md
26
CHANGELOG.md
@ -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
|
||||||
|
|
||||||
|
|||||||
103
src/app/api/account/members/[userId]/route.ts
Normal file
103
src/app/api/account/members/[userId]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/account/members/route.ts
Normal file
73
src/app/api/account/members/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/app/api/account/route.ts
Normal file
88
src/app/api/account/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/app/api/account/transfer-ownership/route.ts
Normal file
79
src/app/api/account/transfer-ownership/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
283
supabase/migrations/018_account_member_rpcs.sql
Normal file
283
supabase/migrations/018_account_member_rpcs.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user