1
0
cvsa/packages/tracker/app/projects/settings.tsx

513 lines
14 KiB
TypeScript

import type { Route } from "./+types/settings";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, Save, Trash2, UserPlus, UserMinus, Search, X } from "lucide-react";
import { Link, Form, redirect } from "react-router";
import { db } from "@lib/db";
import {
projects,
users,
projectPermissions,
type Project,
type User,
type ProjectPermission
} from "@lib/db/schema";
import { getCurrentUser } from "@lib/auth-utils";
import Layout from "@/components/layout";
import { eq, and, like } from "drizzle-orm";
import { canUserEditProject } from "@lib/auth";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { UserSearchModal } from "@/components/project/UserSearch";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Project Settings" },
{ name: "description", content: "Manage project settings and permissions" }
];
}
export async function loader({ request, params }: Route.LoaderArgs) {
const user = await getCurrentUser(request);
if (!user) {
throw new Response("Unauthorized", { status: 401 });
}
const projectId = params.id;
if (!projectId) {
throw new Response("Project ID required", { status: 400 });
}
// Get project details
const project = await db.select().from(projects).where(eq(projects.id, projectId)).get();
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Check if user can edit this project
const canEdit = await canUserEditProject(user.id, projectId);
if (!canEdit) {
throw new Response("Forbidden", { status: 403 });
}
// Get all users for the user management section
const allUsers = await db.select().from(users).orderBy(users.username);
// Get current project permissions
const currentPermissions = await db
.select()
.from(projectPermissions)
.where(eq(projectPermissions.projectId, projectId));
const isOwner = await db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.ownerId, user.id)))
.get();
return { project, allUsers, currentPermissions, currentUser: user, isOwner };
}
export async function action({ request, params }: Route.ActionArgs) {
const user = await getCurrentUser(request);
const projectId = params.id;
if (!projectId) {
throw new Response("Project ID required", { status: 400 });
}
const project = await db.select().from(projects).where(eq(projects.id, projectId)).get();
if (!user) {
throw new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const intent = formData.get("intent") as string;
// Check if user can edit this project
const canEdit = await canUserEditProject(user.id, projectId);
if (!canEdit) {
throw new Response("Forbidden", { status: 403 });
}
const isOwner = await db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.ownerId, user.id)))
.get();
if (intent === "updateProject") {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const isPublic = isOwner ? formData.get("isPublic") === "on" : project?.isPublic;
if (!name) {
return { error: "Project name is required" };
}
await db
.update(projects)
.set({
name,
description,
isPublic,
updatedAt: new Date()
})
.where(eq(projects.id, projectId));
return redirect(`/project/${projectId}`);
}
// Danger zone (below): only project owner have access
if (!project || project.ownerId !== user.id) {
throw new Response("You do not have permission to edit this project", { status: 403 });
}
if (intent === "deleteProject") {
await db.delete(projects).where(eq(projects.id, projectId));
return redirect(`/projects`);
}
if (intent === "addUser") {
const userId = formData.get("userId") as string;
const canEditPermission = formData.get("canEdit") === "on";
if (!userId) {
return { error: "User ID is required" };
}
// Check if permission already exists
const existingPermission = await db
.select()
.from(projectPermissions)
.where(
and(
eq(projectPermissions.projectId, projectId),
eq(projectPermissions.userId, userId)
)
)
.get();
if (existingPermission) {
return { error: "User already has permission for this project" };
}
await db.insert(projectPermissions).values({
id: crypto.randomUUID(),
projectId,
userId,
canEdit: canEditPermission,
createdAt: new Date()
});
return redirect(`/project/${projectId}`);
}
if (intent === "removeUser") {
const userId = formData.get("userId") as string;
if (!userId) {
return { error: "User ID is required" };
}
// Don't allow removing the project owner
const project = await db.select().from(projects).where(eq(projects.id, projectId)).get();
if (project && project.ownerId === userId) {
return { error: "Cannot remove project owner" };
}
await db
.delete(projectPermissions)
.where(
and(
eq(projectPermissions.projectId, projectId),
eq(projectPermissions.userId, userId)
)
);
return redirect(`/project/${projectId}`);
}
if (intent === "updatePermission") {
const userId = formData.get("userId") as string;
const canEdit = formData.get("canEdit") === "on";
if (!userId) {
return { error: "User ID is required" };
}
// Don't allow changing the project owner's permissions
const project = await db.select().from(projects).where(eq(projects.id, projectId)).get();
if (project && project.ownerId === userId) {
return { error: "Cannot change project owner's permissions" };
}
await db
.update(projectPermissions)
.set({ canEdit })
.where(
and(
eq(projectPermissions.projectId, projectId),
eq(projectPermissions.userId, userId)
)
);
return redirect(`/project/${projectId}`);
}
return { error: "Unknown action" };
}
interface UsersManagementProps {
project: Project;
availableUsers: User[];
currentPermissions: ProjectPermission[];
allUsers: User[];
}
export function UsersManagement({
project,
availableUsers,
currentPermissions,
allUsers
}: UsersManagementProps) {
return (
<Card className="mb-6">
<CardHeader>
<CardTitle>User Permissions</CardTitle>
<CardDescription>Manage who can view and edit this project</CardDescription>
</CardHeader>
<CardContent>
{/* Add User Form */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4">Add User</h3>
<UserSearchModal availableUsers={availableUsers} projectId={project.id} />
</div>
{/* Current Users Table */}
<div>
<h3 className="text-lg font-semibold mb-4">Current Users</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Role</TableHead>
<TableHead>Permissions</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Project Owner */}
<TableRow>
<TableCell className="font-medium">
{allUsers.find((u) => u.id === project.ownerId)?.username}
<Badge variant="default" className="ml-2">
Owner
</Badge>
</TableCell>
<TableCell>Owner</TableCell>
<TableCell>Full Access</TableCell>
<TableCell>-</TableCell>
</TableRow>
{/* Users with permissions */}
{currentPermissions.map((permission) => {
const user = allUsers.find((u) => u.id === permission.userId);
if (!user) return null;
return (
<TableRow key={permission.id}>
<TableCell className="font-medium">
{user.username}
</TableCell>
<TableCell>Collaborator</TableCell>
<TableCell>
<Form method="post">
<input
type="hidden"
name="intent"
value="updatePermission"
/>
<input
type="hidden"
name="userId"
value={user.id}
/>
<div className="flex items-center gap-2">
<Checkbox
id={`canEdit-${user.id}`}
name="canEdit"
defaultChecked={permission.canEdit || false}
className="rounded border-gray-300"
onCheckedChange={(e) => {
const canEdit = e;
const formData = new FormData();
formData.append(
"intent",
"updatePermission"
);
formData.append("userId", user.id);
formData.append(
"canEdit",
canEdit ? "on" : ""
);
fetch(
`/project/${project.id}/settings`,
{
method: "POST",
body: formData
}
);
}}
/>
<Label htmlFor={`canEdit-${user.id}`}>
Can Edit
</Label>
</div>
</Form>
</TableCell>
<TableCell>
<Form method="post">
<input
type="hidden"
name="intent"
value="removeUser"
/>
<input
type="hidden"
name="userId"
value={user.id}
/>
<Button
type="submit"
variant="destructive"
size="sm"
>
<UserMinus className="size-4 mr-1" />
Remove
</Button>
</Form>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}
export default function ProjectSettings({ loaderData }: Route.ComponentProps) {
const { project, allUsers, currentPermissions, currentUser } = loaderData;
// Create a map of user permissions for easy lookup
const userPermissions = new Map();
currentPermissions.forEach((permission) => {
userPermissions.set(permission.userId, permission);
});
// Get users who don't have permissions yet
const availableUsers = allUsers.filter(
(user) => !userPermissions.has(user.id) && user.id !== project.ownerId
);
return (
<Layout>
{/* Header */}
<div className="max-sm:flex-col max-sm:gap-6 flex sm:items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Project Settings</h1>
<p className="text-muted-foreground mt-2">
Manage project details and user permissions
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link to={`/project/${project.id}`}>
<ArrowLeft className="size-4.5 mr-1" />
Back to Project
</Link>
</Button>
</div>
</div>
{/* Project Details */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Project Details</CardTitle>
<CardDescription>
Update project name, description, and visibility
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="updateProject" />
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
name="name"
defaultValue={project.name}
placeholder="Enter project name"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
defaultValue={project.description || ""}
placeholder="Enter project description (optional)"
rows={3}
/>
</div>
{loaderData.isOwner && (
<div className="flex items-center gap-2">
<Checkbox
id="isPublic"
name="isPublic"
defaultChecked={project.isPublic || false}
className="rounded border-gray-300"
/>
<Label htmlFor="isPublic">Public Project</Label>
</div>
)}
<div>
<Button type="submit">
<Save className="size-4 mr-1" />
Save Changes
</Button>
</div>
</div>
</Form>
</CardContent>
</Card>
{loaderData.isOwner && (
<UsersManagement
project={project}
allUsers={allUsers}
currentPermissions={currentPermissions}
availableUsers={availableUsers}
/>
)}
{/* Danger Zone */}
{project.ownerId === currentUser.id && (
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Permanently delete this project and all its data
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="deleteProject" />
<Button
type="submit"
variant="destructive"
onClick={(e) => {
if (
!confirm(
"Are you sure you want to delete this project? This action cannot be undone."
)
) {
e.preventDefault();
}
}}
>
<Trash2 className="size-4 mr-1" />
Delete Project
</Button>
</Form>
</CardContent>
</Card>
)}
</Layout>
);
}