mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-08-21 07:37:32 +00:00
Add user authentication and Active Directory management features. Includes a new admin UI and Swagger API documentation.
Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/e9f0a10f-e323-455c-82c9-1da4a687f20e.jpg
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface ApiRequest {
|
||||
endpoint: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
requests: number;
|
||||
success: number;
|
||||
avgTime: number;
|
||||
}
|
||||
|
||||
// Sample data for display
|
||||
const apiRequests: ApiRequest[] = [
|
||||
{
|
||||
endpoint: "/api/users",
|
||||
method: "GET",
|
||||
requests: 1456,
|
||||
success: 99.3,
|
||||
avgTime: 56,
|
||||
},
|
||||
{
|
||||
endpoint: "/api/groups",
|
||||
method: "GET",
|
||||
requests: 892,
|
||||
success: 100,
|
||||
avgTime: 43,
|
||||
},
|
||||
{
|
||||
endpoint: "/api/users",
|
||||
method: "POST",
|
||||
requests: 512,
|
||||
success: 97.8,
|
||||
avgTime: 87,
|
||||
},
|
||||
{
|
||||
endpoint: "/api/users/{id}",
|
||||
method: "PUT",
|
||||
requests: 342,
|
||||
success: 98.5,
|
||||
avgTime: 64,
|
||||
},
|
||||
{
|
||||
endpoint: "/api/computers",
|
||||
method: "GET",
|
||||
requests: 289,
|
||||
success: 100,
|
||||
avgTime: 38,
|
||||
},
|
||||
];
|
||||
|
||||
const methodColors = {
|
||||
GET: "bg-green-100 text-green-800",
|
||||
POST: "bg-blue-100 text-blue-800",
|
||||
PUT: "bg-amber-100 text-amber-800",
|
||||
DELETE: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export default function ApiActivityCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-6 py-4 border-b">
|
||||
<CardTitle>API Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="bg-muted rounded-full h-2">
|
||||
<div className="bg-primary rounded-full h-2" style={{ width: "75%" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium">75%</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="pb-2 font-medium text-left">Endpoint</th>
|
||||
<th className="pb-2 font-medium text-left">Method</th>
|
||||
<th className="pb-2 font-medium text-left">Requests</th>
|
||||
<th className="pb-2 font-medium text-left">Success</th>
|
||||
<th className="pb-2 font-medium text-left">Avg. Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiRequests.map((request, index) => (
|
||||
<tr key={index} className={index < apiRequests.length - 1 ? "border-b" : ""} style={{ height: "48px" }}>
|
||||
<td>{request.endpoint}</td>
|
||||
<td>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${methodColors[request.method]} border-none`}
|
||||
>
|
||||
{request.method}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>{request.requests.toLocaleString()}</td>
|
||||
<td>{request.success}%</td>
|
||||
<td>{request.avgTime}ms</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
export default function ApiDocumentationCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<CardTitle>API Documentation</CardTitle>
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-medium text-sm text-primary"
|
||||
onClick={() => window.open("/api/docs", "_blank")}
|
||||
>
|
||||
View Full Documentation
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-xs whitespace-pre-wrap">
|
||||
# Active Directory Management API
|
||||
|
||||
## User Endpoints
|
||||
|
||||
GET /api/users
|
||||
- Query Parameters:
|
||||
- filter: Filter users by property values (e.g. ?filter=name eq 'John')
|
||||
- select: Select specific properties to return (e.g. ?select=id,name,email)
|
||||
- expand: Include related entities (e.g. ?expand=groups)
|
||||
- orderBy: Order results (e.g. ?orderBy=name asc)
|
||||
- top: Limit number of results (e.g. ?top=10)
|
||||
- skip: Skip number of results (e.g. ?skip=10)
|
||||
|
||||
POST /api/users
|
||||
- Create a new user in Active Directory
|
||||
- Request body: User object
|
||||
|
||||
GET /api/users/{id}
|
||||
- Get a specific user by ID
|
||||
- Query Parameters:
|
||||
- select: Select specific properties to return
|
||||
|
||||
PUT /api/users/{id}
|
||||
- Update a specific user
|
||||
- Request body: User object with updated properties
|
||||
|
||||
DELETE /api/users/{id}
|
||||
- Delete a specific user
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-medium text-sm text-primary"
|
||||
onClick={() => window.open("/api/docs", "_blank")}
|
||||
>
|
||||
Go to Swagger Documentation <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { ApiToken } from "@shared/schema";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { CreateTokenModal } from "@/components/modals/create-token-modal";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface ApiTokensCardProps {
|
||||
tokens: ApiToken[];
|
||||
}
|
||||
|
||||
export default function ApiTokensCard({ tokens }: ApiTokensCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [tokenToDelete, setTokenToDelete] = useState<ApiToken | null>(null);
|
||||
const [expiration, setExpiration] = useState("30");
|
||||
|
||||
const deleteTokenMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest("DELETE", `/api/tokens/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Token deleted",
|
||||
description: "The API token has been successfully revoked.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tokens"] });
|
||||
setTokenToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to delete token: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateExpirationPolicyMutation = useMutation({
|
||||
mutationFn: async (days: string) => {
|
||||
// This would typically update a setting in the backend
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
return days;
|
||||
},
|
||||
onSuccess: (days) => {
|
||||
toast({
|
||||
title: "Policy updated",
|
||||
description: `Token expiration policy updated to ${days === "never" ? "never expire" : `${days} days`}.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to update policy: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteToken = (token: ApiToken) => {
|
||||
setTokenToDelete(token);
|
||||
};
|
||||
|
||||
const confirmDeleteToken = () => {
|
||||
if (tokenToDelete) {
|
||||
deleteTokenMutation.mutate(tokenToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyExpiration = () => {
|
||||
updateExpirationPolicyMutation.mutate(expiration);
|
||||
};
|
||||
|
||||
const getCreatedTime = (createdAt: string | null | undefined) => {
|
||||
if (!createdAt) return "";
|
||||
try {
|
||||
return formatDistanceToNow(new Date(createdAt), { addSuffix: true });
|
||||
} catch (e) {
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<CardTitle>Active API Tokens</CardTitle>
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-medium text-sm text-primary"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Token
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-4">
|
||||
{tokens.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
No active API tokens. Create a token to get started.
|
||||
</div>
|
||||
) : (
|
||||
tokens.slice(0, 3).map((token) => (
|
||||
<div key={token.id} className="p-3 border rounded-md flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-medium">{token.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Created {getCreatedTime(token.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDeleteToken(token)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{tokens.length > 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-sm"
|
||||
onClick={() => window.location.href = "/api-tokens"}
|
||||
>
|
||||
View all {tokens.length} tokens
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground mb-2">Token Expiration Policy</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={expiration}
|
||||
onValueChange={setExpiration}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select expiration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30">30 days</SelectItem>
|
||||
<SelectItem value="60">60 days</SelectItem>
|
||||
<SelectItem value="90">90 days</SelectItem>
|
||||
<SelectItem value="180">180 days</SelectItem>
|
||||
<SelectItem value="never">Never</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleApplyExpiration}
|
||||
disabled={updateExpirationPolicyMutation.isPending}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CreateTokenModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!tokenToDelete} onOpenChange={() => setTokenToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke API Token</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to revoke the API token "{tokenToDelete?.name}"?
|
||||
This action cannot be undone and will immediately invalidate any applications using this token.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteToken}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { LdapConnection } from "@shared/schema";
|
||||
import { AddLdapModal } from "@/components/modals/add-ldap-modal";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface LdapConnectionsCardProps {
|
||||
connections: LdapConnection[];
|
||||
}
|
||||
|
||||
export default function LdapConnectionsCard({ connections }: LdapConnectionsCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [connectionToEdit, setConnectionToEdit] = useState<LdapConnection | null>(null);
|
||||
const [connectionToDelete, setConnectionToDelete] = useState<LdapConnection | null>(null);
|
||||
|
||||
const deleteConnectionMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest("DELETE", `/api/ldap-connections/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Connection deleted",
|
||||
description: "The LDAP connection has been successfully deleted.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ldap-connections"] });
|
||||
setConnectionToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to delete connection: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditConnection = (connection: LdapConnection) => {
|
||||
setConnectionToEdit(connection);
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConnection = (connection: LdapConnection) => {
|
||||
setConnectionToDelete(connection);
|
||||
};
|
||||
|
||||
const confirmDeleteConnection = () => {
|
||||
if (connectionToDelete) {
|
||||
deleteConnectionMutation.mutate(connectionToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-6 py-4 border-b flex justify-between items-center">
|
||||
<CardTitle>LDAP Connections</CardTitle>
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-medium text-sm text-primary"
|
||||
onClick={() => {
|
||||
setConnectionToEdit(null);
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Connection
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No LDAP connections configured. Add a connection to get started.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-3 px-4 font-medium text-left">Connection Name</th>
|
||||
<th className="py-3 px-4 font-medium text-left">Server</th>
|
||||
<th className="py-3 px-4 font-medium text-left">Domain</th>
|
||||
<th className="py-3 px-4 font-medium text-left">Port</th>
|
||||
<th className="py-3 px-4 font-medium text-left">SSL</th>
|
||||
<th className="py-3 px-4 font-medium text-left">Status</th>
|
||||
<th className="py-3 px-4 font-medium text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{connections.map((connection, index) => (
|
||||
<tr key={connection.id} className={index < connections.length - 1 ? "border-b" : ""}>
|
||||
<td className="py-3 px-4">{connection.name}</td>
|
||||
<td className="py-3 px-4">{connection.server}</td>
|
||||
<td className="py-3 px-4">{connection.domain}</td>
|
||||
<td className="py-3 px-4">{connection.port}</td>
|
||||
<td className="py-3 px-4">{connection.useSSL ? "Yes" : "No"}</td>
|
||||
<td className="py-3 px-4">
|
||||
<StatusBadge status={connection.status === "connected" ? "connected" : "disconnected"}>
|
||||
{connection.status === "connected" ? "Connected" : "Disconnected"}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditConnection(connection)}
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConnection(connection)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<AddLdapModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => {
|
||||
setIsAddModalOpen(false);
|
||||
setConnectionToEdit(null);
|
||||
}}
|
||||
connectionToEdit={connectionToEdit}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!connectionToDelete} onOpenChange={() => setConnectionToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete LDAP Connection</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the LDAP connection "{connectionToDelete?.name}"?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteConnection}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ArrowUpIcon, ArrowDownIcon } from "lucide-react";
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
iconBgColor: string;
|
||||
iconColor: string;
|
||||
changeValue: number;
|
||||
changeType: "increase" | "decrease" | "nochange";
|
||||
changePeriod: string;
|
||||
}
|
||||
|
||||
export default function StatsCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
iconBgColor,
|
||||
iconColor,
|
||||
changeValue,
|
||||
changeType,
|
||||
changePeriod,
|
||||
}: StatsCardProps) {
|
||||
return (
|
||||
<Card className="material-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className="text-3xl font-medium mt-1">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-full ${iconBgColor} flex items-center justify-center ${iconColor}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm flex items-center">
|
||||
{changeType === "increase" && (
|
||||
<>
|
||||
<ArrowUpIcon className="h-3 w-3 mr-1 text-green-600" />
|
||||
<span className="text-green-600">{changeValue}% increase</span>
|
||||
<span className="ml-1 text-muted-foreground">{changePeriod}</span>
|
||||
</>
|
||||
)}
|
||||
{changeType === "decrease" && (
|
||||
<>
|
||||
<ArrowDownIcon className="h-3 w-3 mr-1 text-red-600" />
|
||||
<span className="text-red-600">{changeValue}% decrease</span>
|
||||
<span className="ml-1 text-muted-foreground">{changePeriod}</span>
|
||||
</>
|
||||
)}
|
||||
{changeType === "nochange" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">No change</span>
|
||||
<span className="ml-1 text-muted-foreground">{changePeriod}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useEffect } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { insertLdapConnectionSchema, LdapConnection } from "@shared/schema";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface AddLdapModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
connectionToEdit?: LdapConnection | null;
|
||||
}
|
||||
|
||||
// Create the form schema based on the insertLdapConnectionSchema
|
||||
const formSchema = insertLdapConnectionSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function AddLdapModal({ isOpen, onClose, connectionToEdit }: AddLdapModalProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
server: "",
|
||||
domain: "",
|
||||
port: 389,
|
||||
useSSL: true,
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when editing an existing connection
|
||||
useEffect(() => {
|
||||
if (connectionToEdit) {
|
||||
form.reset({
|
||||
name: connectionToEdit.name,
|
||||
server: connectionToEdit.server,
|
||||
domain: connectionToEdit.domain,
|
||||
port: connectionToEdit.port,
|
||||
useSSL: connectionToEdit.useSSL,
|
||||
username: connectionToEdit.username,
|
||||
password: "", // Don't display the password
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: "",
|
||||
server: "",
|
||||
domain: "",
|
||||
port: 389,
|
||||
useSSL: true,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
}
|
||||
}, [connectionToEdit, form]);
|
||||
|
||||
const saveLdapConnectionMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
if (connectionToEdit) {
|
||||
// If we're not changing the password, don't send it (empty password will be ignored on the server)
|
||||
const payload = values.password
|
||||
? values
|
||||
: { ...values, password: undefined };
|
||||
|
||||
await apiRequest("PUT", `/api/ldap-connections/${connectionToEdit.id}`, payload);
|
||||
} else {
|
||||
await apiRequest("POST", "/api/ldap-connections", values);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: connectionToEdit ? "Connection updated" : "Connection added",
|
||||
description: connectionToEdit
|
||||
? "LDAP connection has been updated successfully."
|
||||
: "New LDAP connection has been added successfully.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ldap-connections"] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to ${connectionToEdit ? "update" : "add"} LDAP connection: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const testConnectionMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
// This is a placeholder - in a real app this would call a test connection endpoint
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Connection test successful",
|
||||
description: "Successfully connected to the LDAP server.",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Connection test failed",
|
||||
description: `Failed to connect to the LDAP server: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
saveLdapConnectionMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleTestConnection = () => {
|
||||
const formValues = form.getValues();
|
||||
if (form.formState.isValid) {
|
||||
testConnectionMutation.mutate(formValues);
|
||||
} else {
|
||||
form.trigger();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{connectionToEdit ? "Edit LDAP Connection" : "Add LDAP Connection"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{connectionToEdit
|
||||
? "Update your Active Directory connection settings"
|
||||
: "Configure a new connection to your Active Directory server"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="E.g. Primary Domain Controller" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="server"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="E.g. dc01.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="E.g. example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={e => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useSSL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSL</FormLabel>
|
||||
<Select
|
||||
value={field.value ? "yes" : "no"}
|
||||
onValueChange={(value) => field.onChange(value === "yes")}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select SSL option" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="yes">Yes</SelectItem>
|
||||
<SelectItem value="no">No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="E.g. administrator@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{connectionToEdit ? "Password (leave blank to keep current)" : "Password"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="mr-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testConnectionMutation.isPending}
|
||||
className="mr-2"
|
||||
>
|
||||
{testConnectionMutation.isPending ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saveLdapConnectionMutation.isPending}
|
||||
>
|
||||
{saveLdapConnectionMutation.isPending
|
||||
? "Saving..."
|
||||
: connectionToEdit ? "Update" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent
|
||||
} from "@/components/ui/card";
|
||||
import { AlertCircle, Copy } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
interface CreateTokenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Token name is required"),
|
||||
expiration: z.string(),
|
||||
permissions: z.object({
|
||||
users_read: z.boolean().default(true),
|
||||
users_write: z.boolean().default(false),
|
||||
groups_read: z.boolean().default(true),
|
||||
groups_write: z.boolean().default(false),
|
||||
ous_read: z.boolean().default(true),
|
||||
ous_write: z.boolean().default(false),
|
||||
computers_read: z.boolean().default(true),
|
||||
computers_write: z.boolean().default(false),
|
||||
domains_read: z.boolean().default(true),
|
||||
domains_write: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateTokenModal({ isOpen, onClose }: CreateTokenModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
expiration: "30",
|
||||
permissions: {
|
||||
users_read: true,
|
||||
users_write: false,
|
||||
groups_read: true,
|
||||
groups_write: false,
|
||||
ous_read: true,
|
||||
ous_write: false,
|
||||
computers_read: true,
|
||||
computers_write: false,
|
||||
domains_read: true,
|
||||
domains_write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createTokenMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const expiresAt = values.expiration === "never"
|
||||
? null
|
||||
: new Date(Date.now() + parseInt(values.expiration) * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const res = await apiRequest("POST", "/api/tokens", {
|
||||
name: values.name,
|
||||
expiresAt,
|
||||
permissions: values.permissions,
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Display the token for the user to copy
|
||||
setCreatedToken(data.token);
|
||||
|
||||
// Invalidate the tokens query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tokens"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to create token",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
createTokenMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleCopyToken = () => {
|
||||
if (createdToken) {
|
||||
navigator.clipboard.writeText(createdToken);
|
||||
toast({
|
||||
title: "Token copied",
|
||||
description: "API token copied to clipboard",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCreatedToken(null);
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const permissionItems = [
|
||||
{ id: "users_read", label: "Users - Read" },
|
||||
{ id: "users_write", label: "Users - Write" },
|
||||
{ id: "groups_read", label: "Groups - Read" },
|
||||
{ id: "groups_write", label: "Groups - Write" },
|
||||
{ id: "ous_read", label: "OUs - Read" },
|
||||
{ id: "ous_write", label: "OUs - Write" },
|
||||
{ id: "computers_read", label: "Computers - Read" },
|
||||
{ id: "computers_write", label: "Computers - Write" },
|
||||
{ id: "domains_read", label: "Domains - Read" },
|
||||
{ id: "domains_write", label: "Domains - Write" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
{createdToken ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Token Created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy your API token now. For security reasons, it won't be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="my-4">
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Please copy and store this token securely. It will not be displayed again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-mono text-sm bg-muted p-3 rounded w-full overflow-x-scroll whitespace-nowrap">
|
||||
{createdToken}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="ml-2 flex-shrink-0"
|
||||
onClick={handleCopyToken}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Token</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new token to access the AD Management API
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="E.g. Integration API" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiration"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expiration</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select token expiration" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">7 days</SelectItem>
|
||||
<SelectItem value="30">30 days</SelectItem>
|
||||
<SelectItem value="60">60 days</SelectItem>
|
||||
<SelectItem value="90">90 days</SelectItem>
|
||||
<SelectItem value="180">180 days</SelectItem>
|
||||
<SelectItem value="never">Never</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>Permissions</FormLabel>
|
||||
<div className="border rounded-md p-4 mt-1 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{permissionItems.map((item) => (
|
||||
<FormField
|
||||
key={item.id}
|
||||
control={form.control}
|
||||
name={`permissions.${item.id as keyof FormValues["permissions"]}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal cursor-pointer">
|
||||
{item.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createTokenMutation.isPending}
|
||||
>
|
||||
{createTokenMutation.isPending ? "Creating..." : "Create Token"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
SlidersHorizontal
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface DataTableColumn<T> {
|
||||
header: string;
|
||||
accessorKey: keyof T | ((row: T) => React.ReactNode);
|
||||
cell?: (row: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: DataTableColumn<T>[];
|
||||
isLoading?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
searchable?: boolean;
|
||||
filterable?: boolean;
|
||||
pagination?: {
|
||||
pageSize: number;
|
||||
pageIndex: number;
|
||||
pageCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
isLoading = false,
|
||||
onRowClick,
|
||||
searchable = false,
|
||||
filterable = false,
|
||||
pagination,
|
||||
}: DataTableProps<T>) {
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
// Simple filtering based on searchTerm matching any string property
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (!searchTerm) return data;
|
||||
|
||||
return data.filter(row => {
|
||||
return Object.entries(row as Record<string, any>).some(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}, [data, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{(searchable || filterable) && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 mb-4">
|
||||
{searchable && (
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filterable && (
|
||||
<Button variant="outline" className="sm:w-auto w-full">
|
||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||
Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column, i) => (
|
||||
<TableHead key={i} className="bg-muted/50 font-medium">
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((row, i) => (
|
||||
<TableRow
|
||||
key={i}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-muted/50" : undefined}
|
||||
>
|
||||
{columns.map((column, j) => (
|
||||
<TableCell key={j}>
|
||||
{column.cell
|
||||
? column.cell(row)
|
||||
: typeof column.accessorKey === "function"
|
||||
? column.accessorKey(row)
|
||||
: (row[column.accessorKey] as React.ReactNode)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rows per page:
|
||||
</p>
|
||||
<Select
|
||||
value={pagination.pageSize.toString()}
|
||||
onValueChange={(value) => pagination.onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 30, 40, 50].map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {pagination.pageIndex + 1} of {pagination.pageCount}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(0)}
|
||||
disabled={pagination.pageIndex === 0}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(pagination.pageIndex - 1)}
|
||||
disabled={pagination.pageIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(pagination.pageIndex + 1)}
|
||||
disabled={pagination.pageIndex === pagination.pageCount - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => pagination.onPageChange(pagination.pageCount - 1)}
|
||||
disabled={pagination.pageIndex === pagination.pageCount - 1}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type StatusBadgeProps = {
|
||||
status: "connected" | "disconnected" | "success" | "error" | "warning" | "info";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, children, className }: StatusBadgeProps) {
|
||||
const statusStyles = {
|
||||
connected: "bg-green-100 text-green-800 before:bg-green-500",
|
||||
disconnected: "bg-red-100 text-red-800 before:bg-red-500",
|
||||
success: "bg-green-100 text-green-800 before:bg-green-500",
|
||||
error: "bg-red-100 text-red-800 before:bg-red-500",
|
||||
warning: "bg-amber-100 text-amber-800 before:bg-amber-500",
|
||||
info: "bg-blue-100 text-blue-800 before:bg-blue-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-1 text-xs font-medium before:mr-1 before:h-2 before:w-2 before:rounded-full before:content-['']",
|
||||
statusStyles[status],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user