1
0
cvsa/packages/tracker/app/admin/users.tsx

368 lines
11 KiB
TypeScript

import type { Route } from "./+types/users";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Plus, Trash2, Edit, Shield, ShieldOff, UserPlus } from "lucide-react";
import { Link, Form } from "react-router";
import { db } from "@lib/db";
import { users } from "@lib/db/schema";
import { getCurrentUser } from "@lib/auth-utils";
import Layout from "@/components/layout";
import { eq } from "drizzle-orm";
import { hashPassword } from "@lib/auth";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { generate as generateId } from "@alikia/random-key";
import { Checkbox } from "@/components/ui/checkbox";
export function meta({}: Route.MetaArgs) {
return [
{ title: "User Management - Admin" },
{ name: "description", content: "Manage users and permissions" }
];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getCurrentUser(request);
if (!user || !user.isAdmin) {
throw new Response("You do not have permission to view this page", { status: 403 });
}
// Fetch all users
const allUsers = await db.select().from(users).orderBy(users.createdAt);
return { users: allUsers, currentUser: user };
}
export async function action({ request }: Route.ActionArgs) {
const user = await getCurrentUser(request);
if (!user || !user.isAdmin) {
throw new Response("You do not have permission to view this page", { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
const userId = formData.get("userId") as string;
if (intent === "toggleAdmin") {
const targetUser = await db.select().from(users).where(eq(users.id, userId)).get();
if (!targetUser) {
return { error: "User not found" };
}
// Prevent self-demotion
if (targetUser.id === user.id) {
return { error: "Cannot change your own admin status" };
}
await db
.update(users)
.set({ isAdmin: !targetUser.isAdmin })
.where(eq(users.id, userId));
return { success: true };
}
if (intent === "deleteUser") {
const targetUser = await db.select().from(users).where(eq(users.id, userId)).get();
if (!targetUser) {
return { error: "User not found" };
}
// Prevent self-deletion
if (targetUser.id === user.id) {
return { error: "Cannot delete your own account" };
}
// Prevent deleting admin users
if (targetUser.isAdmin) {
return { error: "Cannot delete admin users" };
}
await db.delete(users).where(eq(users.id, userId));
return { success: true };
}
if (intent === "createUser") {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const isAdmin = formData.get("isAdmin") === "on";
if (!username || !password) {
return { error: "Username and password are required" };
}
// Check if username already exists
const existingUser = await db.select().from(users).where(eq(users.username, username)).get();
if (existingUser) {
return { error: "Username already exists" };
}
const hashedPassword = await hashPassword(password);
await db.insert(users).values({
id: await generateId(6),
username,
password: hashedPassword,
isAdmin,
createdAt: new Date(),
updatedAt: new Date()
});
return { success: true };
}
if (intent === "updateUser") {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const isAdmin = formData.get("isAdmin") === "on";
if (!username) {
return { error: "Username is required" };
}
const targetUser = await db.select().from(users).where(eq(users.id, userId)).get();
if (!targetUser) {
return { error: "User not found" };
}
// Check if username already exists (excluding current user)
const existingUser = await db.select().from(users).where(eq(users.username, username)).get();
if (existingUser && existingUser.id !== userId) {
return { error: "Username already exists" };
}
const updateData: any = {
username,
isAdmin,
};
// Only update password if provided
if (password) {
updateData.password = await hashPassword(password);
}
await db
.update(users)
.set(updateData)
.where(eq(users.id, userId));
return { success: true };
}
return { error: "Unknown action" };
}
export default function UserManagement({ loaderData }: Route.ComponentProps) {
const { users, currentUser } = loaderData;
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">User Management</h1>
<p className="text-muted-foreground mt-2">Manage users and their permissions</p>
</div>
<div className="flex gap-2">
<Dialog>
<DialogTrigger asChild>
<Button>
<UserPlus className="size-4.5 mr-1" />
Add User
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New User</DialogTitle>
<DialogDescription>
Create a new user account with username and password.
</DialogDescription>
</DialogHeader>
<Form method="post">
<input type="hidden" name="intent" value="createUser" />
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
placeholder="Enter username"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter password"
required
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isAdmin"
name="isAdmin"
className="rounded border-gray-300"
/>
<Label htmlFor="isAdmin">Admin User</Label>
</div>
</div>
<DialogFooter>
<Button type="submit">Create User</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<Button variant="outline" asChild>
<Link to="/">
<ArrowLeft className="size-4.5 mr-1" />
Back to Projects
</Link>
</Button>
</div>
</div>
{/* Users Table */}
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>Manage user accounts and permissions</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Admin</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>
{user.isAdmin ? (
<Badge variant="default">Admin</Badge>
) : (
<Badge variant="secondary">User</Badge>
)}
</TableCell>
<TableCell>
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Edit className="size-4 mr-1" />
Edit
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update user information and permissions.
</DialogDescription>
</DialogHeader>
<Form method="post">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="intent" value="updateUser" />
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor={`username-${user.id}`}>Username</Label>
<Input
id={`username-${user.id}`}
name="username"
defaultValue={user.username}
placeholder="Enter username"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`password-${user.id}`}>New Password</Label>
<Input
id={`password-${user.id}`}
name="password"
type="password"
placeholder="Leave blank to keep current password"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id={`isAdmin-${user.id}`}
name="isAdmin"
defaultChecked={user.isAdmin || false}
className="rounded border-gray-300"
disabled={user.id === currentUser.id}
/>
<Label htmlFor={`isAdmin-${user.id}`}>Admin User</Label>
{user.id === currentUser.id && (
<span className="text-xs text-muted-foreground">(Cannot change your own admin status)</span>
)}
</div>
</div>
<DialogFooter>
<Button type="submit">Update User</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<Form method="post">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="intent" value="toggleAdmin" />
<Button
type="submit"
variant="outline"
size="sm"
disabled={user.id === currentUser.id} // Prevent self-demotion
>
{user.isAdmin ? (
<ShieldOff className="size-4 mr-1" />
) : (
<Shield className="size-4 mr-1" />
)}
{user.isAdmin ? "Remove Admin" : "Make Admin"}
</Button>
</Form>
<Form method="post">
<input type="hidden" name="userId" value={user.id} />
<input type="hidden" name="intent" value="deleteUser" />
<Button
type="submit"
variant="destructive"
size="sm"
disabled={user.isAdmin || user.id === currentUser.id} // Prevent deleting admin or self
>
<Trash2 className="size-4 mr-1" />
Delete
</Button>
</Form>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</Layout>
);
}