From 616302a5c7c21d5a34c8d344131c861b421df484 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 16:40:34 +0000 Subject: [PATCH] Gate user-access page behind admin privilege; allow admin role assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../[portfolioId]/colloborators/route.ts | 45 +++-- .../[portfolioId]/invitations/route.ts | 11 +- src/app/lib/portfolioAdmin.test.ts | 35 ++++ src/app/lib/portfolioAdmin.ts | 19 +++ src/app/lib/resolvePortfolioPrivilege.ts | 65 +++++++ .../settings/UsersPermissionsCard.tsx | 158 +++++++++++------- .../[slug]/(portfolio)/settings/roles.tsx | 34 +++- 7 files changed, 285 insertions(+), 82 deletions(-) create mode 100644 src/app/lib/portfolioAdmin.test.ts create mode 100644 src/app/lib/portfolioAdmin.ts create mode 100644 src/app/lib/resolvePortfolioPrivilege.ts diff --git a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts index b48c61f3..3fc72965 100644 --- a/src/app/api/portfolio/[portfolioId]/colloborators/route.ts +++ b/src/app/api/portfolio/[portfolioId]/colloborators/route.ts @@ -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; @@ -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); diff --git a/src/app/api/portfolio/[portfolioId]/invitations/route.ts b/src/app/api/portfolio/[portfolioId]/invitations/route.ts index 33ccdba0..6409dad0 100644 --- a/src/app/api/portfolio/[portfolioId]/invitations/route.ts +++ b/src/app/api/portfolio/[portfolioId]/invitations/route.ts @@ -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; diff --git a/src/app/lib/portfolioAdmin.test.ts b/src/app/lib/portfolioAdmin.test.ts new file mode 100644 index 00000000..23afc564 --- /dev/null +++ b/src/app/lib/portfolioAdmin.test.ts @@ -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); + }); +}); diff --git a/src/app/lib/portfolioAdmin.ts b/src/app/lib/portfolioAdmin.ts new file mode 100644 index 00000000..6798ecfd --- /dev/null +++ b/src/app/lib/portfolioAdmin.ts @@ -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" + ); +} diff --git a/src/app/lib/resolvePortfolioPrivilege.ts b/src/app/lib/resolvePortfolioPrivilege.ts new file mode 100644 index 00000000..4a23a15a --- /dev/null +++ b/src/app/lib/resolvePortfolioPrivilege.ts @@ -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 { + 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 { + 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; +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx index 323ffefe..6e6d58d9 100644 --- a/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/settings/UsersPermissionsCard.tsx @@ -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 { +type CollaboratorsResponse = { + users: Collaborator[]; + currentUser?: { privilege: PortfolioPrivilege }; +}; + +async function getPortfolioUsers( + portfolioId: string, +): Promise { 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 { 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(usersKey); - queryClient.setQueryData(usersKey, (old) => - (old ?? []).map((c) => - c.portfolioUserId === portfolioUserId ? { ...c, role } : c, - ), + const previous = + queryClient.getQueryData(usersKey); + queryClient.setQueryData(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(usersKey); - queryClient.setQueryData(usersKey, (old) => - (old ?? []).filter((c) => c.portfolioUserId !== portfolioUserId), + const previous = + queryClient.getQueryData(usersKey); + queryClient.setQueryData(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 }) { - {/* Invite row */} - - - Add a user -

- Invite by email and choose a role -

-
- - setInviteName(e.target.value)} - /> - setInviteEmail(e.target.value)} - /> -
- -
-
- - - -
+ {/* Invite row — admin-only */} + {isAdmin && ( + + + Add a user +

+ Invite by email and choose a role +

+
+ + setInviteName(e.target.value)} + /> + setInviteEmail(e.target.value)} + /> +
+ +
+
+ + + +
+ )} {/* Current collaborators list */} @@ -381,21 +424,22 @@ export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) { {c.name || "—"} {c.email} - {c.role === "creator" || c.role === "admin" ? ( + {c.role === "creator" || !isAdmin ? ( {c.role} ) : ( onChangeRole(c.portfolioUserId, r) } + allowAdminPromotion /> )} - {c.role !== "creator" && ( + {c.role !== "creator" && isAdmin && (