mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #85 from Hestia-Homes/feature/add_user_permission_from_settings
Feature/add user permission from settings
This commit is contained in:
commit
b40fe283ad
6 changed files with 531 additions and 2 deletions
205
src/app/api/portfolio/[portfolioId]/colloborators/route.ts
Normal file
205
src/app/api/portfolio/[portfolioId]/colloborators/route.ts
Normal 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 collaborator’s 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 you’re 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 can’t 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
46
src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx
Normal file
46
src/app/portfolio/[slug]/(portfolio)/settings/roles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue