mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
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:
parent
07acf4d93d
commit
616302a5c7
7 changed files with 285 additions and 82 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
35
src/app/lib/portfolioAdmin.test.ts
Normal file
35
src/app/lib/portfolioAdmin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
src/app/lib/portfolioAdmin.ts
Normal file
19
src/app/lib/portfolioAdmin.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
65
src/app/lib/resolvePortfolioPrivilege.ts
Normal file
65
src/app/lib/resolvePortfolioPrivilege.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue