your role-change mutation is throwing a blank Error {}. That usually means the client is catching an exception without surfacing the server’s payload, or the request shape doesn’t match what the server expects (so it 400/403s).

Here’s a tight fix path + drop-ins so you’re unblocked fast.

1) Client: make the mutation explicit and error-verbose

Use this exact mutation. It logs the real payload and shows it in the toast so we stop seeing Error {}.

// src/features/admin/useChangeUserRole.ts
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";

type Role = "user" | "admin" | "owner";

export function useChangeUserRole() {
  return useMutation({
    mutationFn: async ({ userId, role }: { userId: string; role: Role }) => {
      // PATCH is typical; use whatever your server routes accept
      const res = await apiRequest<{
        ok: true;
        user: { id: string; role: Role };
      }>("PATCH", `/api/admin/users/${userId}/role`, { role });
      return res;
    },
    onSuccess: (res) => {
      toast({
        title: "Role updated",
        description: `User is now ${res.user.role}.`,
      });
    },
    onError: (err: any) => {
      // Our apiRequest attaches .status and .payload
      const details =
        (err?.payload && JSON.stringify(err.payload)) ||
        err?.message ||
        "Unknown error";
      console.error("[Change role] failed:", err);
      toast({
        title: "Failed to change role",
        description: details,
        variant: "destructive",
      });
    },
  });
}


Hook it up in your Admin UI:

// src/pages/admin/UserRoleCard.tsx (excerpt)
import { useChangeUserRole } from "@/features/admin/useChangeUserRole";

const { mutate: changeRole, isPending } = useChangeUserRole();

<Select
  value={user.role}
  onValueChange={(val) =>
    changeRole({ userId: user.id, role: val as "user" | "admin" | "owner" })
  }
  disabled={isPending}
/>

2) Server: accept { role }, validate, and return a clear JSON

This is the most common route shape. It prevents demoting the last owner, blocks non-owners from promoting/demoting, and returns a payload your client expects.

// src/server/routes/adminUsers.ts
import express, { Request, Response } from "express";
import { z } from "zod";
import { requireAuth, requireRole } from "../middleware/auth"; // adjust paths
import { prisma } from "../prisma"; // adjust import

const router = express.Router();

const Role = z.enum(["user", "admin", "owner"]);
const BodySchema = z.object({ role: Role });

router.patch(
  "/users/:userId/role",
  requireAuth,             // ensure session user present
  requireRole("owner"),    // only owners can change roles (tweak if you allow admins)
  async (req: Request, res: Response) => {
    try {
      const userId = req.params.userId;
      const parsed = BodySchema.safeParse(req.body);
      if (!parsed.success) {
        return res.status(400).json({ error: "Invalid role" });
      }
      const newRole = parsed.data.role;

      const target = await prisma.user.findUnique({ where: { id: userId } });
      if (!target) return res.status(404).json({ error: "User not found" });

      // Guard: don’t demote last owner
      const ownersCount = await prisma.user.count({ where: { role: "owner" } });
      if (target.role === "owner" && newRole !== "owner" && ownersCount <= 1) {
        return res.status(400).json({ error: "Cannot demote the last owner" });
      }

      const updated = await prisma.user.update({
        where: { id: userId },
        data: { role: newRole },
        select: { id: true, role: true },
      });

      return res.json({ ok: true, user: updated });
    } catch (e: any) {
      console.error("[PATCH /users/:id/role] error", e);
      return res.status(500).json({ error: "Server error updating role" });
    }
  }
);

export default router;


Wire it:

// src/server/index.ts
import adminUsersRoutes from "./routes/adminUsers";
app.use("/api/admin", adminUsersRoutes);

3) Quick checks (do these once)

UI → Network tab: when changing the select, you should see PATCH /api/admin/users/:id/role with JSON body {"role":"admin"} (or owner/user). Response should be 200 with { ok: true, user: { id, role } }.

Auth middleware: ensure your requireAuth sets req.user and requireRole("owner") allows you. If you’re the only owner, you’re good.

Enum mismatch: confirm your DB role enum matches "user" | "admin" | "owner". If your DB uses "USER" | "ADMIN" (uppercase), either normalize on the server or adjust the Zod schema accordingly.

CSRF/cookies: our apiRequest uses credentials: 'include'. If your server expects a CSRF token, include it in headers here.

4) If your server already accepts a different shape

If your route expects { newRole } or the role in the query string, just change the mutation:

apiRequest("PATCH", `/api/admin/users/${userId}/role?role=${role}`);


—but keep the onError so we see the real payload next time.