Merge pull request #85 from Hestia-Homes/feature/add_user_permission_from_settings

Feature/add user permission from settings
This commit is contained in:
KhalimCK 2025-09-09 22:19:36 +08:00 committed by GitHub
commit b40fe283ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 531 additions and 2 deletions

View file

@ -0,0 +1,205 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
recommendationMaterials,
planRecommendations,
plan,
scenario,
} from "@/app/db/schema/recommendations";
import {
propertyTargets,
propertyDetailsEpc,
property,
} from "@/app/db/schema/property";
import { eq, inArray, Name } from "drizzle-orm";
import { z } from "zod";
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
// Get colloborators (users) that have access to the portfolio
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
try {
const rows = await db
.select({
portfolioUserId: portfolioUsers.id,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
name: user.firstName,
email: user.email,
})
.from(portfolioUsers)
.leftJoin(user, eq(user.id, portfolioUsers.userId))
.where(eq(portfolioUsers.portfolioId, BigInt(portfolioId)));
// Explicitly normalize BigInts to strings
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString(): null,
userId: r.userId ? r.userId.toString() : null,
role: r.role,
name: r.name ?? null,
email: r.email ?? "",
}));
return NextResponse.json({ users: collaborators }, { status: 200 });
} catch (err) {
console.error("GET /users error:", err);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// PUT: update a collaborators role
export async function PUT(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
// Validate request body
const bodySchema = z.object({
portfolioUserId: z.string(),
role: z.enum(ROLE_OPTIONS), // adjust to your Role union
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch (err) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
// Update role for this portfolioUserId
await db
.update(portfolioUsers)
.set({ role: body.role })
.where(eq(portfolioUsers.id, BigInt(body.portfolioUserId)));
return NextResponse.json(
{ success: true, portfolioUserId: body.portfolioUserId, role: body.role },
{ status: 200 }
);
} catch (err) {
console.error("PUT /colloborators error:", err);
return NextResponse.json(
{ error: "Failed to update role" },
{ status: 500 }
);
}
}
// POST: invite a user by email (find-or-create user, then add to portfolio with role)
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> }
) {
const { portfolioId } = await props.params;
// 1) Validate payload
const bodySchema = z.object({
email: z.string().email(),
role: z.enum(ROLE_OPTIONS),
name: z.string()
});
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const pId = BigInt(portfolioId);
// 2) Find or create the user by email
// Try to find existing user
let existing = await db
.select({ id: user.id, firstName: user.firstName, email: user.email })
.from(user)
.where(eq(user.email, body.email))
.limit(1);
let createdUserId: bigint | null = existing[0]?.id ?? null;
// If not found, create. Prefer Postgres upsert to avoid race.
if (!createdUserId) {
// If youre on Postgres, this is ideal:
const inserted = await db
.insert(user)
.values({ email: body.email, firstName: body.name, oauthProvider: "credentials" })
.onConflictDoNothing() // relies on a UNIQUE(email) constraint
.returning({ id: user.id });
if (inserted.length > 0) {
createdUserId = inserted[0].id;
} else {
// Someone else created the user concurrently; fetch it
const fetched = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, body.email))
.limit(1);
if (!fetched[0]) {
return NextResponse.json(
{ error: "Failed to create or fetch user" },
{ status: 500 }
);
}
createdUserId = fetched[0].id;
}
}
// 3) Link user to portfolio with role (upsert)
// Assumes a UNIQUE index on (portfolioId, userId) in portfolioUsers.
const linkResult = await db
.insert(portfolioUsers)
.values({
portfolioId: pId,
userId: createdUserId!,
role: body.role,
})
.returning({
portfolioUserId: portfolioUsers.id,
userId: portfolioUsers.userId,
role: portfolioUsers.role,
});
const row = linkResult[0];
if (!row) {
return NextResponse.json(
{ error: "Failed to create portfolio user" },
{ status: 500 }
);
}
const collaborator = {
portfolioUserId: row.portfolioUserId?.toString() ?? null,
userId: row.userId?.toString() ?? null,
role: row.role,
name: body.name ?? null,
email: body.email,
};
// 201 if it was a new link, 200 if it was an update — we cant easily
// tell from .onConflictDoUpdate return, so just use 200 OK.
return NextResponse.json({ user: collaborator }, { status: 200 });
} catch (err) {
console.error("POST /colloborators error:", err);
return NextResponse.json(
{ error: "Failed to invite user" },
{ status: 500 }
);
}
}

View file

@ -1,6 +1,7 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
import { portfolio, portfolioUsers} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
recommendationMaterials,
@ -161,3 +162,5 @@ export async function DELETE(request: NextRequest, props: { params: Promise<{ po
);
}
}

View file

@ -7,7 +7,7 @@ export const user = pgTable("user", {
// At the moment, Drizzle doesn't support unique constraints
email: text("email").notNull(),
oauthId: text("oauth_id"),
oauthProvider: text("oauth_provider").$type<"google">(),
oauthProvider: text("oauth_provider").$type<"google" | "credentials">(),
// role: text("role").$type<"admin" | "write" | "read">(),
createdAt: timestamp("created_at", {
precision: 6,

View file

@ -34,6 +34,7 @@ import { PortfolioStatus as PortfolioStatusOptions } from "@/app/db/schema/portf
import { PortfolioGoal as PortfolioGoalOptions } from "@/app/db/schema/portfolio";
import { useSession } from "next-auth/react";
import PortfolioPlanTable from "@/app/components/portfolio/measures/PlanTable";
import { UsersPermissionsCard } from "./UsersPermissionsCard";
// dropdown selection component for both goal and status
@ -459,6 +460,7 @@ export default function PortfolioSettings({
</TableBody>
</Table>
</div>
<UsersPermissionsCard portfolioId={portfolioId}/>
<div className="rounded-md border border-red-500 mt-2">
<Table>
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>

View file

@ -0,0 +1,273 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { useState, useEffect } from "react";
import { Role, RoleDropdown, Collaborator } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
async function getPortfolioUsers(portfolioId: string): Promise<Collaborator[]> {
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
// Guard + shape to Collaborator[]
return Array.isArray(users)
? users
.filter((u: any) => u.role !== "creator") // 👈 filter out creator
.map((u: any) => ({
portfolioUserId: String(u.portfolioUserId),
userId: String(u.userId),
name: u.name ?? null,
email: u.email ?? "",
role: u.role,
}))
: [];
}
async function updatePortfolioUserRole(
portfolioId: string,
portfolioUserId: string,
role: Role
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ portfolioUserId, role }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || "Failed to update role");
}
}
async function invitePortfolioUser(
portfolioId: string,
email: string,
role: Role,
name: string
): Promise<void> {
const res = await fetch(`/api/portfolio/${portfolioId}/colloborators`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role, name }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || "Failed to invite user");
}
}
export function UsersPermissionsCard({ portfolioId }: { portfolioId: string }) {
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<Role>("read");
const [inviteName, setInviteName] = useState("");
const queryClient = useQueryClient();
const {
data: collaborators = [],
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["portfolioUsers", portfolioId],
queryFn: () => getPortfolioUsers(portfolioId),
enabled: !!portfolioId, // only run when id is present
refetchOnWindowFocus: false, // optional: avoid surprise refetch logs
onSuccess: (data) => {
console.log("Fetched users for portfolio:", data);
},
onError: (err) => {
console.error("Error fetching users:", err);
},
});
const changeRoleMutation = useMutation({
mutationFn: ({ portfolioUserId, role }: { portfolioUserId: string; role: Role }) =>
updatePortfolioUserRole(portfolioId, portfolioUserId, role),
// Optimistic update
onMutate: async ({ portfolioUserId, role }) => {
await queryClient.cancelQueries({ queryKey: ["portfolioUsers", portfolioId] });
const previous = queryClient.getQueryData<Collaborator[]>(["portfolioUsers", portfolioId]);
// Optimistically update cache
queryClient.setQueryData<Collaborator[]>(
["portfolioUsers", portfolioId],
(old) =>
(old ?? []).map((c) =>
c.portfolioUserId === portfolioUserId ? { ...c, role } : c
)
);
// Return context to rollback on error
return { previous };
},
// Rollback on error
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(["portfolioUsers", portfolioId], context.previous);
}
console.error("Failed to update role:", err);
},
// Always revalidate after success/error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] });
},
});
// ADD: mutation for inviting a user
const inviteUserMutation = useMutation({
mutationFn: ({ email, role, name }: { email: string; role: Role; name: string }) =>
invitePortfolioUser(portfolioId, email, role, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portfolioUsers", portfolioId] });
setInviteEmail("");
setInviteName(""); // clear name after success
// setInviteRole("read");
},
onError: (err) => {
console.error("Invite failed:", err);
},
});
function handleInvite() {
inviteUserMutation.mutate({ email: inviteEmail, role: inviteRole, name: inviteName });
}
function onChangeRole(portfolioUserId: string, role: Role) {
console.log(`Change portfolioUserId ${portfolioUserId} to ${role}`);
changeRoleMutation.mutate({ portfolioUserId, role });
}
function onRemove(portfolioUserId: string) {
console.log(`This button will delete the row portoflioUserId ${portfolioUserId}`);
console.log("This was not implemented as Jun-te wanted to avoid Delete via drizzle before Database integrirty")
// TODO: DELETE user -> then refetch()
}
return (
<div className="rounded-md border border-gray-700 mt-2">
<Table>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
Users Permission:
<p className="text-xs text-gray-500">Add users and manage roles</p>
</TableHead>
<TableCell className="text-right">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching || isLoading}>
{isFetching || isLoading ? "Loading..." : "Refresh Users"}
</Button>
</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>
{/* Current collaborators list */}
<TableRow>
<TableHead className="text-brandblue">
Current users
<p className="text-xs text-gray-500">Update roles or remove access</p>
</TableHead>
<TableCell colSpan={2}>
<div className="rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">Loading</TableCell>
</TableRow>
) : collaborators.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-sm text-gray-500">
No users yet.
</TableCell>
</TableRow>
) : (
collaborators.map((c) => (
<TableRow key={c.email}>
<TableCell>{c.name || "—"}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell className="min-w-40">
<RoleDropdown value={c.role} onChange={(r) => onChangeRole(c.portfolioUserId, r)} />
</TableCell>
<TableCell className="text-right">
<Button variant="destructive" className="bg-red-700" onClick={() => onRemove(c.portfolioUserId)}>
Remove
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,46 @@
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
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];
export type Collaborator = {
portfolioUserId: string;
userId: string;
name?: string | null;
email: string;
role: Role;
};
// Small role dropdown using shadcn Select
export function RoleDropdown({
value,
onChange,
}: {
value: Role;
onChange: (role: Role) => void;
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as Role)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={value} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLE_OPTIONS.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}