Gate user-access page behind admin privilege; allow admin role assignment

Adds a portfolio-privilege concept (creator > admin > domna employee >
write > read > none) and gates all user-access mutations + the pending-
invitations view behind it. Plus opens the role dropdown to include
"admin" so creators/admins/Domna can promote and demote.

Privilege model:
  - Portfolio creator: full admin powers (cannot be removed/demoted)
  - Portfolio admin: full admin powers via explicit membership role
  - Domna employee (email ends @domna.homes, case-insensitive):
    implicit admin across all portfolios, even if not a member —
    intended for customer-support / internal-tooling needs
  - Anyone else (read/write/none): no admin powers

Backend:
  - New pure-function helpers in src/app/lib/portfolioAdmin.ts —
    isDomnaEmail() and canAdminister(privilege), with 6 tests covering
    case-insensitivity and look-alike domain rejection
  - New server helper resolvePortfolioPrivilege() that reads
    portfolioUsers + checks the email, returning the highest privilege
  - New denyIfNotAdmin(portfolioId, session) one-liner that returns a
    401/403 NextResponse or null; used at the top of every mutating
    route handler to keep the guard out of the way
  - POST/PUT/DELETE on /colloborators and GET/DELETE on /invitations
    are now all gated. Non-admin callers get 403.
  - GET /colloborators now requires auth and returns
    `{ users, currentUser: { privilege } }` so the UI knows which
    actions to expose without an extra round-trip

Frontend:
  - ROLE_OPTIONS extended to ["read", "write", "admin"]. RoleDropdown
    takes allowAdminPromotion?: boolean to keep the basic dropdown
    unchanged where promotion isn't allowed.
  - UsersPermissionsCard derives isAdmin = canAdminister(privilege)
    from the API response. Invite section, role-change dropdown,
    Remove button, and the entire Pending Invitations section are now
    rendered only when isAdmin. Non-admins see a read-only members
    table.
  - The invitations useQuery is disabled when !isAdmin, avoiding
    guaranteed-403 network calls.

Defensive note: the UI gating is for UX; the backend guard is the
security boundary. A non-admin who hand-crafts a POST still gets 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 16:40:34 +00:00
parent 07acf4d93d
commit 616302a5c7
7 changed files with 285 additions and 82 deletions

View file

@ -25,15 +25,28 @@ import { normaliseEmail } from "@/app/lib/email";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { PortfolioInvitationEmail } from "@/app/email_templates/portfolio_invitation";
import {
denyIfNotAdmin,
resolvePortfolioPrivilege,
} from "@/app/lib/resolvePortfolioPrivilege";
// Get colloborators (users) that have access to the portfolio
// Get colloborators (users) that have access to the portfolio, plus the
// effective privilege of the requesting user (so the UI knows which actions
// to expose).
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
const pId = BigInt(portfolioId);
const rows = await db
.select({
portfolioUserId: portfolioUsers.id,
@ -44,9 +57,8 @@ export async function GET(
})
.from(portfolioUsers)
.leftJoin(user, eq(user.id, portfolioUsers.userId))
.where(eq(portfolioUsers.portfolioId, BigInt(portfolioId)));
.where(eq(portfolioUsers.portfolioId, pId));
// Explicitly normalize BigInts to strings
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null,
userId: r.userId ? r.userId.toString() : null,
@ -55,7 +67,16 @@ export async function GET(
email: r.email ?? "",
}));
return NextResponse.json({ users: collaborators }, { status: 200 });
const privilege = await resolvePortfolioPrivilege({
portfolioId: pId,
userId: BigInt(session.user.dbId),
userEmail: session.user.email,
});
return NextResponse.json(
{ users: collaborators, currentUser: { privilege } },
{ status: 200 },
);
} catch (err) {
console.error("GET /users error:", err);
return NextResponse.json(
@ -73,9 +94,8 @@ export async function DELETE(
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const bodySchema = z.object({ portfolioUserId: z.string() });
let body: z.infer<typeof bodySchema>;
@ -135,6 +155,10 @@ export async function PUT(
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
// Validate request body
const bodySchema = z.object({
portfolioUserId: z.string(),
@ -195,10 +219,9 @@ export async function POST(
const email = normaliseEmail(body.email);
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const inviterUserId = BigInt(session.user.dbId);
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const inviterUserId = BigInt(session!.user!.dbId!);
try {
const pId = BigInt(portfolioId);

View file

@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { denyIfNotAdmin } from "@/app/lib/resolvePortfolioPrivilege";
// GET: list pending invitations for a portfolio. Invitations are consumed
// (deleted) when the invitee signs in, so anything returned here is still
@ -16,9 +17,8 @@ export async function GET(
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
try {
const pId = BigInt(portfolioId);
@ -58,9 +58,8 @@ export async function DELETE(
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const denied = await denyIfNotAdmin(BigInt(portfolioId), session);
if (denied) return denied;
const bodySchema = z.object({ invitationId: z.string() });
let body: z.infer<typeof bodySchema>;

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { canAdminister, isDomnaEmail } from "./portfolioAdmin";
describe("isDomnaEmail", () => {
it("identifies @domna.homes addresses as internal", () => {
expect(isDomnaEmail("khalim@domna.homes")).toBe(true);
});
it("is case-insensitive on the domain", () => {
expect(isDomnaEmail("Khalim@Domna.Homes")).toBe(true);
});
it("rejects look-alike domains and prefixes", () => {
expect(isDomnaEmail("user@example.com")).toBe(false);
expect(isDomnaEmail("user@domna.homes.attacker.com")).toBe(false);
expect(isDomnaEmail("user@notdomna.homes")).toBe(false);
});
});
describe("canAdminister", () => {
it("grants admin powers to portfolio creator", () => {
expect(canAdminister("creator")).toBe(true);
});
it("grants admin powers to portfolio admins and Domna employees", () => {
expect(canAdminister("admin")).toBe(true);
expect(canAdminister("domna")).toBe(true);
});
it("denies admin powers to read/write members and non-members", () => {
expect(canAdminister("write")).toBe(false);
expect(canAdminister("read")).toBe(false);
expect(canAdminister("none")).toBe(false);
});
});

View file

@ -0,0 +1,19 @@
export type PortfolioPrivilege =
| "creator"
| "admin"
| "domna"
| "write"
| "read"
| "none";
export function isDomnaEmail(email: string): boolean {
return email.toLowerCase().endsWith("@domna.homes");
}
export function canAdminister(privilege: PortfolioPrivilege): boolean {
return (
privilege === "creator" ||
privilege === "admin" ||
privilege === "domna"
);
}

View file

@ -0,0 +1,65 @@
import { db } from "@/app/db/db";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import type { Session } from "next-auth";
import {
canAdminister,
isDomnaEmail,
type PortfolioPrivilege,
} from "./portfolioAdmin";
// Resolves the effective privilege a session has on a given portfolio.
// Highest-wins: an explicit "creator" or "admin" membership ranks above the
// implicit "domna" employee privilege; otherwise Domna employees get admin
// powers without being a member; otherwise the membership role is returned.
export async function resolvePortfolioPrivilege({
portfolioId,
userId,
userEmail,
}: {
portfolioId: bigint;
userId: bigint;
userEmail: string;
}): Promise<PortfolioPrivilege> {
const [membership] = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, portfolioId),
eq(portfolioUsers.userId, userId),
),
)
.limit(1);
if (membership?.role === "creator") return "creator";
if (membership?.role === "admin") return "admin";
if (isDomnaEmail(userEmail)) return "domna";
if (membership?.role === "write") return "write";
if (membership?.role === "read") return "read";
return "none";
}
// Convenience: returns an HTTP response if the session can't administer the
// portfolio, otherwise null. Use at the top of mutating route handlers:
//
// const denied = await denyIfNotAdmin(portfolioId, session);
// if (denied) return denied;
export async function denyIfNotAdmin(
portfolioId: bigint,
session: Session | null,
): Promise<NextResponse | null> {
if (!session?.user?.dbId || !session.user.email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const privilege = await resolvePortfolioPrivilege({
portfolioId,
userId: BigInt(session.user.dbId),
userEmail: session.user.email,
});
if (!canAdminister(privilege)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}

View file

@ -18,24 +18,35 @@ import {
Collaborator,
} from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
canAdminister,
type PortfolioPrivilege,
} from "@/app/lib/portfolioAdmin";
type PendingInvitation = {
invitationId: string;
email: string;
role: Role | "creator" | "admin";
role: Role | "creator";
createdAt: string;
};
async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
type CollaboratorsResponse = {
users: Collaborator[];
currentUser?: { privilege: PortfolioPrivilege };
};
async function getPortfolioUsers(
portfolioId: string,
): Promise<CollaboratorsResponse> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("Failed to fetch users");
const json = await res.json();
const users = Array.isArray(json) ? json : json.users; // support both shapes
return Array.isArray(users)
? users.map((u: any) => ({
const rawUsers = Array.isArray(json) ? json : json.users;
const users: Collaborator[] = Array.isArray(rawUsers)
? rawUsers.map((u: any) => ({
portfolioUserId: String(u.portfolioUserId),
userId: String(u.userId),
name: u.name ?? null,
@ -43,6 +54,9 @@ async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
role: u.role,
}))
: [];
const privilege: PortfolioPrivilege | undefined =
json?.currentUser?.privilege;
return privilege ? { users, currentUser: { privilege } } : { users };
}
async function getPortfolioInvitations(
@ -139,7 +153,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
const invitationsKey = ["portfolioInvitations", portfolioId];
const {
data: collaborators = [],
data: collaboratorsData,
isLoading,
isFetching,
refetch,
@ -150,6 +164,11 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
refetchOnWindowFocus: false,
});
const collaborators = collaboratorsData?.users ?? [];
const currentPrivilege: PortfolioPrivilege =
collaboratorsData?.currentUser?.privilege ?? "none";
const isAdmin = canAdminister(currentPrivilege);
const {
data: invitations = [],
isLoading: invitationsLoading,
@ -157,7 +176,9 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
} = useQuery({
queryKey: invitationsKey,
queryFn: () => getPortfolioInvitations(portfolioId),
enabled: !!portfolioId,
// Only admins can see pending invitations — the GET endpoint also enforces
// this; gating here avoids the unauthorised network request.
enabled: !!portfolioId && isAdmin,
refetchOnWindowFocus: false,
});
@ -177,11 +198,17 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
onMutate: async ({ portfolioUserId, role }) => {
await queryClient.cancelQueries({ queryKey: usersKey });
const previous = queryClient.getQueryData<Collaborator[]>(usersKey);
queryClient.setQueryData<Collaborator[]>(usersKey, (old) =>
(old ?? []).map((c) =>
c.portfolioUserId === portfolioUserId ? { ...c, role } : c,
),
const previous =
queryClient.getQueryData<CollaboratorsResponse>(usersKey);
queryClient.setQueryData<CollaboratorsResponse>(usersKey, (old) =>
old
? {
...old,
users: old.users.map((c) =>
c.portfolioUserId === portfolioUserId ? { ...c, role } : c,
),
}
: old,
);
return { previous };
},
@ -222,9 +249,17 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
onMutate: async (portfolioUserId) => {
await queryClient.cancelQueries({ queryKey: usersKey });
const previous = queryClient.getQueryData<Collaborator[]>(usersKey);
queryClient.setQueryData<Collaborator[]>(usersKey, (old) =>
(old ?? []).filter((c) => c.portfolioUserId !== portfolioUserId),
const previous =
queryClient.getQueryData<CollaboratorsResponse>(usersKey);
queryClient.setQueryData<CollaboratorsResponse>(usersKey, (old) =>
old
? {
...old,
users: old.users.filter(
(c) => c.portfolioUserId !== portfolioUserId,
),
}
: old,
);
return { previous };
},
@ -305,43 +340,51 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
</TableCell>
</TableRow>
{/* Invite row */}
<TableRow>
<TableHead className="text-brandblue">
Add a user
<p className="text-xs text-gray-500">
Invite by email and choose a role
</p>
</TableHead>
<TableCell className="flex gap-2 items-center">
<Input
type="text"
placeholder="Full name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<Input
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<div className="min-w-40">
<RoleDropdown value={inviteRole} onChange={setInviteRole} />
</div>
</TableCell>
<TableCell className="text-right">
<Button
className="w-28"
onClick={handleInvite}
disabled={
!inviteEmail || !inviteName || inviteUserMutation.isPending
}
>
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
</Button>
</TableCell>
</TableRow>
{/* Invite row — admin-only */}
{isAdmin && (
<TableRow>
<TableHead className="text-brandblue">
Add a user
<p className="text-xs text-gray-500">
Invite by email and choose a role
</p>
</TableHead>
<TableCell className="flex gap-2 items-center">
<Input
type="text"
placeholder="Full name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<Input
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<div className="min-w-40">
<RoleDropdown
value={inviteRole}
onChange={setInviteRole}
allowAdminPromotion
/>
</div>
</TableCell>
<TableCell className="text-right">
<Button
className="w-28"
onClick={handleInvite}
disabled={
!inviteEmail ||
!inviteName ||
inviteUserMutation.isPending
}
>
{inviteUserMutation.isPending ? "Inviting..." : "Invite"}
</Button>
</TableCell>
</TableRow>
)}
{/* Current collaborators list */}
<TableRow>
@ -381,21 +424,22 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
<TableCell>{c.name || "—"}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell className="min-w-40">
{c.role === "creator" || c.role === "admin" ? (
{c.role === "creator" || !isAdmin ? (
<span className="text-xs font-medium text-gray-500 px-2 py-1 bg-gray-100 rounded-md capitalize">
{c.role}
</span>
) : (
<RoleDropdown
value={c.role as "read" | "write"}
value={c.role as Role}
onChange={(r) =>
onChangeRole(c.portfolioUserId, r)
}
allowAdminPromotion
/>
)}
</TableCell>
<TableCell className="text-right">
{c.role !== "creator" && (
{c.role !== "creator" && isAdmin && (
<Button
variant="destructive"
className="bg-red-700"
@ -419,7 +463,8 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
</TableCell>
</TableRow>
{/* Pending invitations list */}
{/* Pending invitations list — admin-only */}
{isAdmin && (
<TableRow>
<TableHead className="text-brandblue">
Pending invitations
@ -483,6 +528,7 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>

View file

@ -7,34 +7,50 @@ import {
SelectItem,
} from "@/app/shadcn_components/ui/select";
// Roles you support in your app (adjust as needed)
export const ROLE_OPTIONS = ["read", "write"] as const;
export type Role = typeof ROLE_OPTIONS[number];
// Roles a portfolio admin can assign via the UI. "creator" is set on portfolio
// creation only and is not assignable.
export const ROLE_OPTIONS = ["read", "write", "admin"] as const;
export type Role = (typeof ROLE_OPTIONS)[number];
// Roles a non-admin viewer would see in the assignable dropdown — not used
// for backend validation, just shapes the dropdown when promotion isn't
// permitted.
const BASIC_ROLE_OPTIONS = ["read", "write"] as const;
export type Collaborator = {
portfolioUserId: string;
userId: string;
userId: string;
name?: string | null;
email: string;
role: Role | "creator" | "admin";
role: Role | "creator";
};
// Small role dropdown using shadcn Select
// Small role dropdown using shadcn Select. Pass `allowAdminPromotion` when the
// viewer can promote/demote to/from "admin".
export function RoleDropdown({
value,
onChange,
allowAdminPromotion = false,
disabled = false,
}: {
value: Role;
onChange: (role: Role) => void;
allowAdminPromotion?: boolean;
disabled?: boolean;
}) {
const options = allowAdminPromotion ? ROLE_OPTIONS : BASIC_ROLE_OPTIONS;
return (
<Select value={value} onValueChange={(v) => onChange(v as Role)}>
<Select
value={value}
onValueChange={(v) => onChange(v as Role)}
disabled={disabled}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={value} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLE_OPTIONS.map((r) => (
{options.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
@ -43,4 +59,4 @@ export function RoleDropdown({
</SelectContent>
</Select>
);
}
}