Begin plugins system

This commit is contained in:
2025-08-07 01:12:58 -04:00
parent 544207e9a2
commit 3a443d1727
53 changed files with 5873 additions and 2547 deletions

View File

@@ -4,21 +4,21 @@ import { useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import type { SystemRole } from "~/lib/auth-client";
import { formatRole, getAvailableRoles } from "~/lib/auth-client";
@@ -35,7 +35,7 @@ interface UserWithRoles {
export function AdminUserTable() {
const [search, setSearch] = useState("");
const [selectedRole, setSelectedRole] = useState<SystemRole | "">("");
const [selectedRole, setSelectedRole] = useState<SystemRole | "all">("all");
const [page, setPage] = useState(1);
const [selectedUser, setSelectedUser] = useState<UserWithRoles | null>(null);
const [roleToAssign, setRoleToAssign] = useState<SystemRole | "">("");
@@ -48,7 +48,7 @@ export function AdminUserTable() {
page,
limit: 10,
search: search || undefined,
role: selectedRole || undefined,
role: selectedRole === "all" ? undefined : selectedRole,
});
const assignRole = api.users.assignRole.useMutation({
@@ -108,13 +108,15 @@ export function AdminUserTable() {
<Label htmlFor="role-filter">Filter by Role</Label>
<Select
value={selectedRole}
onValueChange={(value) => setSelectedRole(value as SystemRole | "")}
onValueChange={(value) =>
setSelectedRole(value as SystemRole | "all")
}
>
<SelectTrigger>
<SelectValue placeholder="All roles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All roles</SelectItem>
<SelectItem value="all">All roles</SelectItem>
{availableRoles.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}

View File

@@ -0,0 +1,433 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { formatDistanceToNow } from "date-fns";
import {
Copy,
ExternalLink,
MoreHorizontal,
Database,
RefreshCw,
Settings,
Trash2,
Shield,
CheckCircle,
XCircle,
Clock,
AlertTriangle,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
// Define error type for mutations
interface TRPCError {
message: string;
}
export type Repository = {
id: string;
name: string;
url: string;
description: string | null;
trustLevel: "official" | "verified" | "community";
isEnabled: boolean;
isOfficial: boolean;
lastSyncAt: Date | null;
syncStatus: string | null;
syncError: string | null;
createdAt: Date;
updatedAt: Date;
};
const trustLevelConfig = {
official: {
label: "Official",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
icon: Shield,
description: "Official HRIStudio repository",
},
verified: {
label: "Verified",
className: "bg-green-100 text-green-800 hover:bg-green-200",
icon: Shield,
description: "Verified by the community",
},
community: {
label: "Community",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
icon: Shield,
description: "Community repository",
},
};
const syncStatusConfig = {
pending: {
label: "Pending",
className: "bg-gray-100 text-gray-800",
icon: Clock,
description: "Waiting to sync",
},
syncing: {
label: "Syncing",
className: "bg-blue-100 text-blue-800",
icon: RefreshCw,
description: "Currently syncing",
},
completed: {
label: "Success",
className: "bg-green-100 text-green-800",
icon: CheckCircle,
description: "Last sync completed successfully",
},
failed: {
label: "Failed",
className: "bg-red-100 text-red-800",
icon: AlertTriangle,
description: "Last sync failed",
},
};
function RepositoryActionsCell({ repository }: { repository: Repository }) {
const utils = api.useUtils();
const syncMutation = api.admin.repositories.sync.useMutation({
onSuccess: () => {
toast.success("Repository sync started");
void utils.admin.repositories.list.invalidate();
},
onError: (error: TRPCError) => {
toast.error(error.message ?? "Failed to sync repository");
},
});
const deleteMutation = api.admin.repositories.delete.useMutation({
onSuccess: () => {
toast.success("Repository deleted successfully");
void utils.admin.repositories.list.invalidate();
},
onError: (error: TRPCError) => {
toast.error(error.message ?? "Failed to delete repository");
},
});
const handleSync = async () => {
syncMutation.mutate({ id: repository.id });
};
const handleDelete = async () => {
if (
window.confirm(`Are you sure you want to delete "${repository.name}"?`)
) {
deleteMutation.mutate({ id: repository.id });
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(repository.id);
toast.success("Repository ID copied to clipboard");
};
const handleCopyUrl = () => {
void navigator.clipboard.writeText(repository.url);
toast.success("Repository URL copied to clipboard");
};
const canDelete = !repository.isOfficial;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleSync}
disabled={syncMutation.isPending}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`}
/>
Sync Repository
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/repositories/${repository.id}/edit`}>
<Settings className="mr-2 h-4 w-4" />
Edit Repository
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={repository.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View Repository
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Repository ID
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyUrl}>
<Copy className="mr-2 h-4 w-4" />
Copy Repository URL
</DropdownMenuItem>
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Repository
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const repositoriesColumns: ColumnDef<Repository>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Repository Name" />
),
cell: ({ row }) => {
const repository = row.original;
return (
<div className="max-w-[200px] min-w-0 space-y-1">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<Link
href={`/admin/repositories/${repository.id}`}
className="truncate font-medium hover:underline"
title={repository.name}
>
{repository.name}
</Link>
{repository.isOfficial && (
<Badge variant="outline" className="text-xs">
Official
</Badge>
)}
</div>
{repository.description && (
<p
className="text-muted-foreground line-clamp-1 truncate text-sm"
title={repository.description}
>
{repository.description}
</p>
)}
</div>
);
},
},
{
accessorKey: "url",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Repository URL" />
),
cell: ({ row }) => {
const url = row.original.url;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="max-w-[300px] truncate text-sm text-blue-600 hover:underline"
title={url}
>
{url}
</a>
);
},
},
{
accessorKey: "trustLevel",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Trust Level" />
),
cell: ({ row }) => {
const trustLevel = row.original.trustLevel;
const config = trustLevelConfig[trustLevel];
const TrustIcon = config.icon;
return (
<Badge
variant="secondary"
className={config.className}
title={config.description}
>
<TrustIcon className="mr-1 h-3 w-3" />
{config.label}
</Badge>
);
},
filterFn: (row, id, value: string[]) => {
return value.includes(row.original.trustLevel);
},
},
{
accessorKey: "isEnabled",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const isEnabled = row.original.isEnabled;
return (
<Badge
variant="secondary"
className={
isEnabled
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}
>
{isEnabled ? (
<CheckCircle className="mr-1 h-3 w-3" />
) : (
<XCircle className="mr-1 h-3 w-3" />
)}
{isEnabled ? "Enabled" : "Disabled"}
</Badge>
);
},
filterFn: (row, id, value: string[]) => {
const isEnabled = row.original.isEnabled;
return value.includes(isEnabled ? "enabled" : "disabled");
},
},
{
accessorKey: "syncStatus",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Sync Status" />
),
cell: ({ row }) => {
const syncStatus = row.original.syncStatus;
const lastSyncAt = row.original.lastSyncAt;
const syncError = row.original.syncError;
if (!syncStatus) return "-";
const config =
syncStatusConfig[syncStatus as keyof typeof syncStatusConfig];
if (!config) return syncStatus;
const SyncIcon = config.icon;
return (
<div className="space-y-1">
<Badge
variant="secondary"
className={config.className}
title={config.description}
>
<SyncIcon
className={`mr-1 h-3 w-3 ${syncStatus === "syncing" ? "animate-spin" : ""}`}
/>
{config.label}
</Badge>
{lastSyncAt && syncStatus === "completed" && (
<div className="text-muted-foreground text-xs">
{formatDistanceToNow(lastSyncAt, { addSuffix: true })}
</div>
)}
{syncError && syncStatus === "failed" && (
<div
className="max-w-[150px] truncate text-xs text-red-600"
title={syncError}
>
{syncError}
</div>
)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.original.createdAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
},
{
accessorKey: "updatedAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Updated" />
),
cell: ({ row }) => {
const date = row.original.updatedAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
enableSorting: false,
enableHiding: false,
},
];

View File

@@ -0,0 +1,220 @@
"use client";
import { Plus, Database } from "lucide-react";
import React from "react";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import { EmptyState } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { ActionButton, PageHeader } from "~/components/ui/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { api } from "~/trpc/react";
import {
repositoriesColumns,
type Repository,
} from "~/components/admin/repositories-columns";
export function RepositoriesDataTable() {
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
const [enabledFilter, setEnabledFilter] = React.useState("all");
const {
data: repositoriesData,
isLoading,
error,
refetch,
} = api.admin.repositories.list.useQuery(
{
trustLevel:
trustLevelFilter === "all"
? undefined
: (trustLevelFilter as "official" | "verified" | "community"),
isEnabled:
enabledFilter === "all" ? undefined : enabledFilter === "enabled",
limit: 50,
},
{
refetchOnWindowFocus: false,
},
);
// Auto-refresh repositories when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void refetch();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refetch]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Administration", href: "/admin" },
{ label: "Plugin Repositories" },
]);
// Transform repositories data to match the Repository type expected by columns
const repositories: Repository[] = React.useMemo(() => {
if (!repositoriesData) return [];
return repositoriesData as Repository[];
}, [repositoriesData]);
// Trust level filter options
const trustLevelOptions = [
{ label: "All Trust Levels", value: "all" },
{ label: "Official", value: "official" },
{ label: "Verified", value: "verified" },
{ label: "Community", value: "community" },
];
// Enabled filter options
const enabledOptions = [
{ label: "All Repositories", value: "all" },
{ label: "Enabled", value: "enabled" },
{ label: "Disabled", value: "disabled" },
];
// Filter repositories based on selected filters
const filteredRepositories = React.useMemo(() => {
return repositories.filter((repository) => {
const trustLevelMatch =
trustLevelFilter === "all" ||
repository.trustLevel === trustLevelFilter;
const enabledMatch =
enabledFilter === "all" ||
(enabledFilter === "enabled" && repository.isEnabled) ||
(enabledFilter === "disabled" && !repository.isEnabled);
return trustLevelMatch && enabledMatch;
});
}, [repositories, trustLevelFilter, enabledFilter]);
const filters = (
<div className="flex items-center space-x-2">
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="Trust Level" />
</SelectTrigger>
<SelectContent>
{trustLevelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={enabledFilter} onValueChange={setEnabledFilter}>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{enabledOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// Show error state
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Plugin Repositories"
description="Manage plugin repositories for the HRIStudio platform"
icon={Database}
actions={
<ActionButton href="/admin/repositories/new">
<Plus className="mr-2 h-4 w-4" />
Add Repository
</ActionButton>
}
/>
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<div className="text-red-800">
<h3 className="mb-2 text-lg font-semibold">
Failed to Load Repositories
</h3>
<p className="mb-4">
{(error as unknown as Error)?.message ??
"An error occurred while loading repositories."}
</p>
<Button onClick={() => void refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
</div>
);
}
// Show empty state if no repositories
if (!isLoading && repositories.length === 0) {
return (
<div className="space-y-6">
<PageHeader
title="Plugin Repositories"
description="Manage plugin repositories for the HRIStudio platform"
icon={Database}
actions={
<ActionButton href="/admin/repositories/new">
<Plus className="mr-2 h-4 w-4" />
Add Repository
</ActionButton>
}
/>
<EmptyState
icon="Database"
title="No Plugin Repositories"
description="Add plugin repositories to enable users to browse and install plugins."
action={
<Button asChild>
<a href="/admin/repositories/new">Add First Repository</a>
</Button>
}
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Plugin Repositories"
description="Manage plugin repositories for the HRIStudio platform"
icon={Database}
actions={
<ActionButton href="/admin/repositories/new">
<Plus className="mr-2 h-4 w-4" />
Add Repository
</ActionButton>
}
/>
<div className="space-y-4">
{/* Data Table */}
<DataTable
columns={repositoriesColumns}
data={filteredRepositories}
searchKey="name"
searchPlaceholder="Search repositories..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}